First Commit

This commit is contained in:
2026-05-31 10:17:09 +07:00
commit 17a9c69379
4547 changed files with 1170384 additions and 0 deletions
+322
View File
@@ -0,0 +1,322 @@
# JWT API Authentication for Odoo 19
Modul `grt_jwt_token` menyediakan autentikasi JWT untuk aplikasi eksternal yang ingin mengakses endpoint API Odoo dengan pola:
1. Aplikasi eksternal mengirim `client_id` dan `client_secret`.
2. Odoo memvalidasi API Client.
3. Odoo mengembalikan JWT access token.
4. Aplikasi eksternal memakai token tersebut sebagai `Authorization: Bearer <token>` untuk mengakses endpoint API yang dilindungi.
Modul ini cocok untuk integrasi API dan SSO berbasis token antar aplikasi. Untuk login otomatis ke UI web Odoo, masih diperlukan flow tambahan yang membuat Odoo session secara eksplisit.
## Fitur
- Menu aplikasi utama: **JWT Auth**.
- Model konfigurasi API Client.
- Generate `client_id` otomatis.
- Generate dan rotasi `client_secret`.
- Penyimpanan secret sebagai hash PBKDF2, bukan plaintext.
- Token JWT dengan claim `iss`, `aud`, `exp`, `iat`, `jti`, `sub`, dan `client_id`.
- Decorator `@check_jwt_auth` untuk melindungi controller custom.
- Contoh endpoint protected: `/api/ifc/projects`.
## Struktur Modul
```text
grt_jwt_token/
+-- __manifest__.py
+-- __init__.py
+-- README.md
+-- controllers/
| +-- __init__.py
| +-- main.py
+-- models/
| +-- __init__.py
| +-- api_client.py
+-- security/
| +-- ir.model.access.csv
+-- static/
| +-- description/
| | +-- icon.png
| | +-- icon_128.png
| +-- src/img/
| +-- jwt_auth_icon.png
+-- views/
+-- api_client_views.xml
```
## Konfigurasi Odoo
Tambahkan `jwt_secret` di file `odoo.conf`.
```ini
[options]
jwt_secret = ganti_dengan_secret_panjang_minimal_32_karakter
```
Ketentuan:
- Minimal 32 karakter.
- Jangan pakai secret contoh di production.
- Simpan secret hanya di server.
- Setelah mengubah `odoo.conf`, restart Odoo.
Contoh konfigurasi lokal:
```ini
db_name = odoo19
http_port = 8071
jwt_secret = W2V8bVN38YJZJQhcew8Fcgw6vG5Ab79hbRzD3SpjJvGQxNPvKcNFQ8syb6h9PqRr
```
## Instalasi
1. Pastikan folder `grt_jwt_token` berada di `addons_path`.
2. Restart Odoo.
3. Buka **Apps**.
4. Update Apps List jika modul belum muncul.
5. Install **JWT API Authentication**.
Atau lewat CLI:
```powershell
$env:PYTHONPATH='C:\odoo19\server'
.\.venv\Scripts\python.exe C:\odoo19\server\odoo-bin -c .\odoo.conf -d odoo19 -i grt_jwt_token --stop-after-init
```
Untuk update modul:
```powershell
$env:PYTHONPATH='C:\odoo19\server'
.\.venv\Scripts\python.exe C:\odoo19\server\odoo-bin -c .\odoo.conf -d odoo19 -u grt_jwt_token --stop-after-init
```
## Membuat API Client
1. Login sebagai Administrator.
2. Buka app **JWT Auth**.
3. Buat record baru.
4. Isi:
- **Application Name**: nama aplikasi eksternal.
- **Odoo User**: user Odoo yang akan dipakai ketika token digunakan.
- **Token Lifetime (seconds)**: masa berlaku token, default `7200`.
5. Simpan.
6. Salin `Client ID` dan `New Client Secret`.
Penting: `client_secret` hanya ditampilkan saat dibuat atau setelah tombol **Regenerate Secret** ditekan. Setelah halaman ditutup, secret tidak bisa dilihat lagi karena database hanya menyimpan hash.
## Endpoint Token
URL:
```text
POST /api/auth/token
```
Content-Type:
```text
application/json
```
Body:
```json
{
"client_id": "CLIENT_ID_ANDA",
"client_secret": "CLIENT_SECRET_ANDA"
}
```
`secret_key` juga diterima sebagai alias dari `client_secret` untuk kompatibilitas integrasi.
Response sukses:
```json
{
"access_token": "JWT_TOKEN",
"expires_in": 7200,
"token_type": "Bearer"
}
```
Response gagal:
```json
{
"error": "Invalid credentials."
}
```
## Contoh Request Token
PowerShell:
```powershell
$body = @{
client_id = "CLIENT_ID_ANDA"
client_secret = "CLIENT_SECRET_ANDA"
} | ConvertTo-Json
Invoke-RestMethod `
-Uri "http://localhost:8071/api/auth/token" `
-Method Post `
-Body $body `
-ContentType "application/json"
```
cURL:
```bash
curl -X POST http://localhost:8071/api/auth/token \
-H "Content-Type: application/json" \
-d '{"client_id":"CLIENT_ID_ANDA","client_secret":"CLIENT_SECRET_ANDA"}'
```
## Mengakses Endpoint Protected
Contoh endpoint:
```text
GET /api/ifc/projects
```
Header:
```text
Authorization: Bearer JWT_TOKEN
```
PowerShell:
```powershell
$headers = @{
Authorization = "Bearer JWT_TOKEN"
}
Invoke-RestMethod `
-Uri "http://localhost:8071/api/ifc/projects" `
-Method Get `
-Headers $headers
```
cURL:
```bash
curl http://localhost:8071/api/ifc/projects \
-H "Authorization: Bearer JWT_TOKEN"
```
Response contoh:
```json
{
"message": "JWT token is valid.",
"user_id": 2,
"user_name": "Administrator",
"client_id": "CLIENT_ID_ANDA",
"data": [
"Proyek_IFC_Gedung_A",
"Proyek_IFC_Infrastruktur_B"
]
}
```
## Melindungi Controller Custom
Import decorator dari controller modul:
```python
from odoo import http
from odoo.http import request
from odoo.addons.grt_jwt_token.controllers.main import check_jwt_auth, json_response
class MyApiController(http.Controller):
@http.route('/api/my/resource', type='http', auth='public', methods=['GET'], csrf=False)
@check_jwt_auth
def my_resource(self, **kwargs):
return json_response({
'user_id': request.env.user.id,
'user_name': request.env.user.name,
})
```
Saat token valid, decorator akan:
- Decode dan validasi JWT.
- Memastikan client masih aktif.
- Memastikan user Odoo masih aktif.
- Mengisi `request.jwt_payload`.
- Mengisi `request.jwt_client`.
- Mengisi `request.jwt_user_id`.
- Menjalankan `request.update_env(user=user_id)`.
## Claim JWT
Token berisi claim berikut:
| Claim | Isi |
| --- | --- |
| `iss` | Issuer, default `odoo` |
| `aud` | Audience, default `odoo-api` |
| `exp` | Waktu kedaluwarsa token |
| `iat` | Waktu token diterbitkan |
| `jti` | ID unik token |
| `sub` | ID user Odoo dalam format string |
| `client_id` | Client ID API Client |
## Keamanan
Rekomendasi production:
- Gunakan HTTPS.
- Gunakan `jwt_secret` yang panjang dan random.
- Jangan commit `jwt_secret` production ke repository.
- Buat satu API Client per aplikasi eksternal.
- Mapping API Client ke user Odoo dengan hak akses minimum.
- Rotasi secret secara berkala dengan tombol **Regenerate Secret**.
- Nonaktifkan API Client yang tidak digunakan.
- Batasi masa berlaku token sesuai kebutuhan.
- Tambahkan rate limiting di reverse proxy jika endpoint dibuka ke internet.
## Troubleshooting
### Error: JWT secret is not configured
Penyebab:
- `jwt_secret` belum ada di `odoo.conf`.
- Panjang `jwt_secret` kurang dari 32 karakter.
- Odoo belum direstart setelah config diubah.
Solusi:
1. Tambahkan `jwt_secret` yang valid.
2. Restart Odoo.
### Error: Invalid credentials
Penyebab:
- `client_id` salah.
- `client_secret` salah.
- API Client tidak aktif.
- User Odoo yang dipetakan tidak aktif.
### Error: Token has expired
Token melewati `Token Lifetime (seconds)`. Minta token baru melalui `/api/auth/token`.
### Menu JWT Auth belum muncul
Jika modul sudah ter-install tapi menu belum terlihat:
1. Logout.
2. Hapus local storage browser untuk `webclient_menus` dan `webclient_menus_version`.
3. Login ulang atau pakai private/incognito window.
## Catatan Batasan
Modul ini belum membuat session login web Odoo otomatis. Token JWT dipakai untuk akses endpoint API yang memakai `@check_jwt_auth`. Jika membutuhkan SSO penuh ke UI Odoo, perlu tambahan flow session seperti OAuth/OIDC callback atau endpoint login session yang divalidasi dengan signature.
+2
View File
@@ -0,0 +1,2 @@
from . import models
from . import controllers
+21
View File
@@ -0,0 +1,21 @@
{
'name': 'JWT API Authentication',
'version': '1.0.2',
'category': 'Tools',
'summary': 'JWT authentication endpoint for external applications',
'description': """
JWT API Authentication for Odoo 19.
Provides API Client configuration, JWT token generation, and a decorator for
protecting custom HTTP controllers with Bearer token authentication.
""",
'author': 'GRT',
'depends': ['base', 'web'],
'data': [
'security/ir.model.access.csv',
'views/api_client_views.xml',
],
'installable': True,
'application': True,
'license': 'LGPL-3',
}
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
from . import main
+130
View File
@@ -0,0 +1,130 @@
import datetime
import functools
import json
import uuid
import jwt
from odoo import fields, http
from odoo.http import request
from odoo.tools import config
JWT_ISSUER = 'odoo'
JWT_AUDIENCE = 'odoo-api'
def json_response(payload, status=200):
return request.make_response(
json.dumps(payload),
headers=[('Content-Type', 'application/json')],
status=status,
)
def get_jwt_secret():
secret_key = config.get('jwt_secret')
if not secret_key or len(secret_key) < 32:
return None
return secret_key
def check_jwt_auth(func):
@functools.wraps(func)
def wrapper(self, *args, **kwargs):
auth_header = request.httprequest.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
return json_response({'error': 'Bearer token is required.'}, status=401)
secret_key = get_jwt_secret()
if not secret_key:
return json_response({'error': 'JWT secret is not configured.'}, status=500)
token = auth_header.split(' ', 1)[1].strip()
try:
payload = jwt.decode(
token,
secret_key,
algorithms=['HS256'],
issuer=JWT_ISSUER,
audience=JWT_AUDIENCE,
options={'require': ['exp', 'iat', 'sub', 'client_id']},
)
user_id = int(payload['sub'])
client = request.env['api.client'].sudo().search([
('client_id', '=', payload['client_id']),
('active', '=', True),
('user_id', '=', user_id),
], limit=1)
user = request.env['res.users'].sudo().browse(user_id).exists()
if not client or not user or not user.active:
return json_response({'error': 'Token subject is not allowed.'}, status=401)
request.jwt_payload = payload
request.jwt_client = client
request.jwt_user_id = user_id
request.update_env(user=user_id)
except jwt.ExpiredSignatureError:
return json_response({'error': 'Token has expired.'}, status=401)
except (jwt.InvalidTokenError, ValueError):
return json_response({'error': 'Token is invalid.'}, status=401)
return func(self, *args, **kwargs)
return wrapper
class JwtAuthController(http.Controller):
@http.route('/api/auth/token', type='http', auth='public', methods=['POST'], csrf=False)
def generate_token(self, **kwargs):
data = request.httprequest.get_json(silent=True) or kwargs
client_id = data.get('client_id')
client_secret = data.get('client_secret') or data.get('secret_key')
if not client_id or not client_secret:
return json_response({'error': 'client_id and client_secret are required.'}, status=400)
secret_key = get_jwt_secret()
if not secret_key:
return json_response({'error': 'JWT secret is not configured.'}, status=500)
client = request.env['api.client'].sudo().search([
('client_id', '=', client_id),
('active', '=', True),
], limit=1)
if not client or not client._check_secret(client_secret) or not client.user_id.active:
return json_response({'error': 'Invalid credentials.'}, status=401)
now = datetime.datetime.now(datetime.UTC)
expires_at = now + datetime.timedelta(seconds=client.token_ttl)
payload = {
'iss': JWT_ISSUER,
'aud': JWT_AUDIENCE,
'exp': expires_at,
'iat': now,
'jti': str(uuid.uuid4()),
'sub': str(client.user_id.id),
'client_id': client.client_id,
}
token = jwt.encode(payload, secret_key, algorithm='HS256')
client.last_used = fields.Datetime.now()
return json_response({
'access_token': token,
'expires_in': client.token_ttl,
'token_type': 'Bearer',
})
@http.route('/api/ifc/projects', type='http', auth='public', methods=['GET', 'POST'], csrf=False)
@check_jwt_auth
def get_ifc_projects(self, **kwargs):
return json_response({
'message': 'JWT token is valid.',
'user_id': request.env.user.id,
'user_name': request.env.user.name,
'client_id': request.jwt_payload.get('client_id'),
'data': ['Proyek_IFC_Gedung_A', 'Proyek_IFC_Infrastruktur_B'],
})
+1
View File
@@ -0,0 +1 @@
from . import api_client
+113
View File
@@ -0,0 +1,113 @@
import base64
import hashlib
import secrets
from odoo import api, fields, models
from odoo.exceptions import UserError
from odoo.tools.misc import consteq
class ApiClient(models.Model):
_name = 'api.client'
_description = 'API Client for JWT Authentication'
_rec_name = 'name'
name = fields.Char(string='Application Name', required=True)
active = fields.Boolean(default=True)
client_id = fields.Char(
string='Client ID',
required=True,
default=lambda self: secrets.token_urlsafe(24),
copy=False,
readonly=True,
)
client_secret = fields.Char(
string='New Client Secret',
copy=False,
store=False,
help='Shown only before saving. Use Regenerate Secret to rotate it.',
)
client_secret_hash = fields.Char(string='Client Secret Hash', copy=False, readonly=True)
user_id = fields.Many2one(
'res.users',
string='Odoo User',
required=True,
domain=[('active', '=', True)],
help='Odoo user mapped to JWT tokens issued for this client.',
)
token_ttl = fields.Integer(string='Token Lifetime (seconds)', default=7200, required=True)
last_used = fields.Datetime(readonly=True)
_sql_constraints = [
('client_id_unique', 'unique(client_id)', 'Client ID must be unique.'),
('token_ttl_positive', 'check(token_ttl > 0)', 'Token lifetime must be positive.'),
]
@api.model_create_multi
def create(self, vals_list):
generated_secrets = []
for vals in vals_list:
plain_secret = vals.pop('client_secret', None) or self._generate_secret()
vals['client_secret_hash'] = self._hash_secret(plain_secret)
generated_secrets.append(plain_secret)
records = super().create(vals_list)
for record, plain_secret in zip(records, generated_secrets):
record.client_secret = plain_secret
return records
def write(self, vals):
if 'client_secret' in vals:
plain_secret = vals.pop('client_secret')
if plain_secret:
vals['client_secret_hash'] = self._hash_secret(plain_secret)
return super().write(vals)
def action_regenerate_secret(self):
for client in self:
plain_secret = self._generate_secret()
client.write({'client_secret': plain_secret})
client.client_secret = plain_secret
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Client secret regenerated',
'message': 'Copy the new secret from the form before leaving this page.',
'type': 'warning',
'sticky': True,
},
}
@api.model
def _generate_secret(self):
return secrets.token_urlsafe(48)
@api.model
def _hash_secret(self, secret):
if not secret:
raise UserError('Client secret cannot be empty.')
iterations = 260000
salt = secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac('sha256', secret.encode(), salt, iterations)
return '$'.join((
'pbkdf2_sha256',
str(iterations),
base64.urlsafe_b64encode(salt).decode(),
base64.urlsafe_b64encode(digest).decode(),
))
def _check_secret(self, secret):
self.ensure_one()
try:
scheme, iterations, salt, expected = self.client_secret_hash.split('$', 3)
if scheme != 'pbkdf2_sha256':
return False
digest = hashlib.pbkdf2_hmac(
'sha256',
secret.encode(),
base64.urlsafe_b64decode(salt.encode()),
int(iterations),
)
return consteq(base64.urlsafe_b64encode(digest).decode(), expected)
except Exception:
return False
@@ -0,0 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_api_client_system,api.client system,model_api_client,base.group_system,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_api_client_system api.client system model_api_client base.group_system 1 1 1 1
Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

+56
View File
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_api_client_tree" model="ir.ui.view">
<field name="name">api.client.tree</field>
<field name="model">api.client</field>
<field name="arch" type="xml">
<list string="API Clients">
<field name="name"/>
<field name="client_id"/>
<field name="user_id"/>
<field name="active"/>
<field name="last_used"/>
</list>
</field>
</record>
<record id="view_api_client_form" model="ir.ui.view">
<field name="name">api.client.form</field>
<field name="model">api.client</field>
<field name="arch" type="xml">
<form string="API Client">
<header>
<button name="action_regenerate_secret" type="object" string="Regenerate Secret" class="btn-primary"/>
</header>
<sheet>
<group>
<group>
<field name="name"/>
<field name="active"/>
<field name="user_id"/>
<field name="token_ttl"/>
</group>
<group>
<field name="client_id"/>
<field name="client_secret" password="True" placeholder="Shown only when generated or rotated"/>
<field name="last_used"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_api_client" model="ir.actions.act_window">
<field name="name">API Clients (JWT)</field>
<field name="res_model">api.client</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_api_client_root" name="JWT Auth" action="action_api_client" sequence="100" web_icon="grt_jwt_token,static/description/icon.png"/>
<record id="menu_api_client_root" model="ir.ui.menu">
<field name="parent_id" eval="False"/>
<field name="group_ids" eval="[(4, ref('base.group_system'))]"/>
</record>
</odoo>