diff --git a/backend/.env.development b/backend/.env.development index 9732013..2a3b9a3 100644 --- a/backend/.env.development +++ b/backend/.env.development @@ -14,7 +14,9 @@ AUTHENTIK_VERIFY_TLS=true AUTHENTIK_ISSUER=https://auth.ose.tw/application/o/member-ose-frontend/ AUTHENTIK_JWKS_URL=https://auth.ose.tw/application/o/member-ose-frontend/jwks/ AUTHENTIK_AUDIENCE=gKtjk5ExsITK74I1WG9RkHbylBjoZO83xab7YHiN +AUTHENTIK_CLIENT_ID=gKtjk5ExsITK74I1WG9RkHbylBjoZO83xab7YHiN AUTHENTIK_CLIENT_SECRET=MHTv0SHkIuic9Quk8Br9jB9gzT2bERvRfhHU4ogPlUtY3eBEXJj80RTEp3zpFBUXQ8PAwYrihWfNqKawWUOmKpQd8SwuyiAuVwLJTS7vB3LGvx1XtXqgMhR76EL2mLnP +AUTHENTIK_TOKEN_ENDPOINT=https://auth.ose.tw/application/o/token/ PUBLIC_FRONTEND_ORIGINS=http://127.0.0.1:5173,http://localhost:5173 INTERNAL_SHARED_SECRET=CHANGE_ME diff --git a/backend/.env.example b/backend/.env.example index 9021329..ba37b10 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -14,7 +14,9 @@ AUTHENTIK_VERIFY_TLS=false AUTHENTIK_ISSUER= AUTHENTIK_JWKS_URL= AUTHENTIK_AUDIENCE= +AUTHENTIK_CLIENT_ID= AUTHENTIK_CLIENT_SECRET= +AUTHENTIK_TOKEN_ENDPOINT= PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw INTERNAL_SHARED_SECRET=CHANGE_ME diff --git a/backend/.env.production.example b/backend/.env.production.example index b4bde73..3afbb04 100644 --- a/backend/.env.production.example +++ b/backend/.env.production.example @@ -14,7 +14,9 @@ AUTHENTIK_VERIFY_TLS=false AUTHENTIK_ISSUER= AUTHENTIK_JWKS_URL= AUTHENTIK_AUDIENCE= +AUTHENTIK_CLIENT_ID= AUTHENTIK_CLIENT_SECRET= +AUTHENTIK_TOKEN_ENDPOINT= PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw INTERNAL_SHARED_SECRET=CHANGE_ME diff --git a/backend/README.md b/backend/README.md index dd96435..401e095 100644 --- a/backend/README.md +++ b/backend/README.md @@ -28,7 +28,9 @@ python scripts/generate_api_key_hash.py 'YOUR_PLAIN_KEY' - `AUTHENTIK_ISSUER` (the service infers `/jwks/`) - Optional: - `AUTHENTIK_AUDIENCE` (enables audience claim validation) + - `AUTHENTIK_CLIENT_ID` (used by `/auth/login`, fallback to `AUTHENTIK_AUDIENCE`) - `AUTHENTIK_CLIENT_SECRET` (required if your access/id token uses HS256 signing) + - `AUTHENTIK_TOKEN_ENDPOINT` (default: `/application/o/token/`) ## Authentik Admin API setup @@ -40,6 +42,7 @@ python scripts/generate_api_key_hash.py 'YOUR_PLAIN_KEY' ## Main APIs - `GET /healthz` +- `POST /auth/login` - `GET /me` (Bearer token required) - `GET /me/permissions/snapshot` (Bearer token required) - `POST /internal/users/upsert-by-sub` diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..d631383 --- /dev/null +++ b/backend/app/api/auth.py @@ -0,0 +1,57 @@ +from urllib.parse import urljoin + +import httpx +from fastapi import APIRouter, HTTPException, status + +from app.core.config import get_settings +from app.schemas.login import LoginRequest, LoginResponse + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/login", response_model=LoginResponse) +def login(payload: LoginRequest) -> LoginResponse: + settings = get_settings() + client_id = settings.authentik_client_id or settings.authentik_audience + + if not settings.authentik_base_url or not client_id or not settings.authentik_client_secret: + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="authentik_login_not_configured") + + token_endpoint = settings.authentik_token_endpoint or urljoin( + settings.authentik_base_url.rstrip("/") + "/", "application/o/token/" + ) + + form = { + "grant_type": "password", + "client_id": client_id, + "client_secret": settings.authentik_client_secret, + "username": payload.username, + "password": payload.password, + "scope": "openid profile email", + } + + try: + resp = httpx.post( + token_endpoint, + data=form, + timeout=10, + verify=settings.authentik_verify_tls, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + except Exception as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_unreachable") from exc + + if resp.status_code >= 400: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_username_or_password") + + data = resp.json() + token = data.get("access_token") + if not token: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_missing_access_token") + + return LoginResponse( + access_token=token, + token_type=data.get("token_type", "Bearer"), + expires_in=data.get("expires_in"), + scope=data.get("scope"), + ) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index c7d9969..63175ed 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -23,7 +23,9 @@ class Settings(BaseSettings): authentik_issuer: str = "" authentik_jwks_url: str = "" authentik_audience: str = "" + authentik_client_id: str = "" authentik_client_secret: str = "" + authentik_token_endpoint: str = "" public_frontend_origins: Annotated[list[str], NoDecode] = ["https://member.ose.tw"] internal_shared_secret: str = "" diff --git a/backend/app/main.py b/backend/app/main.py index 8d776fd..7d35d36 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,6 +2,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.api.admin import router as admin_router +from app.api.auth import router as auth_router from app.api.internal import router as internal_router from app.api.me import router as me_router from app.core.config import get_settings @@ -26,3 +27,4 @@ def healthz() -> dict[str, str]: app.include_router(internal_router) app.include_router(admin_router) app.include_router(me_router) +app.include_router(auth_router) diff --git a/backend/app/schemas/login.py b/backend/app/schemas/login.py new file mode 100644 index 0000000..ffb2e18 --- /dev/null +++ b/backend/app/schemas/login.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel + + +class LoginRequest(BaseModel): + username: str + password: str + + +class LoginResponse(BaseModel): + access_token: str + token_type: str = "Bearer" + expires_in: int | None = None + scope: str | None = None diff --git a/docs/FRONTEND_API_CONTRACT.md b/docs/FRONTEND_API_CONTRACT.md index 0b257d9..ca2aa19 100644 --- a/docs/FRONTEND_API_CONTRACT.md +++ b/docs/FRONTEND_API_CONTRACT.md @@ -2,6 +2,31 @@ Base URL:`https://memberapi.ose.tw` +## 0. 帳號密碼登入 +### POST `/auth/login` +Request: +```json +{ + "username": "your-authentik-username", + "password": "your-password" +} +``` + +200 Response: +```json +{ + "access_token": "", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "openid profile email" +} +``` + +401 Response: +```json +{ "detail": "invalid_username_or_password" } +``` + ## 1. 使用者資訊 ### GET `/me` Headers: diff --git a/docs/FRONTEND_ARCHITECTURE.md b/docs/FRONTEND_ARCHITECTURE.md index c889c9d..a822e09 100644 --- a/docs/FRONTEND_ARCHITECTURE.md +++ b/docs/FRONTEND_ARCHITECTURE.md @@ -44,7 +44,8 @@ ## 5. Token 與 Header 策略 - 使用者路由(`/me*`) - - header: `Authorization: Bearer ` + - 登入用 `POST /auth/login`(帳號密碼)取得 access token + - header: `Authorization: Bearer ` - 管理路由(`/admin*`) - headers: - `X-Client-Key` diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js new file mode 100644 index 0000000..a8bce76 --- /dev/null +++ b/frontend/src/api/auth.js @@ -0,0 +1,4 @@ +import { userHttp } from './http' + +export const loginWithPassword = (username, password) => + userHttp.post('/auth/login', { username, password }) diff --git a/frontend/src/pages/LoginPage.vue b/frontend/src/pages/LoginPage.vue index b9e9176..59f4c6e 100644 --- a/frontend/src/pages/LoginPage.vue +++ b/frontend/src/pages/LoginPage.vue @@ -4,21 +4,27 @@ - + + + + 登入 -

- Token 從 Authentik 取得,存於本機 localStorage -

+

登入成功後 access token 會存於本機 localStorage

@@ -53,29 +57,34 @@ import { ref } from 'vue' import { useRouter, useRoute } from 'vue-router' import { useAuthStore } from '@/stores/auth' +import { loginWithPassword } from '@/api/auth' const router = useRouter() const route = useRoute() const authStore = useAuthStore() -const token = ref('') +const username = ref('') +const password = ref('') const loading = ref(false) const error = ref('') async function handleLogin() { - if (!token.value.trim()) return + if (!username.value.trim() || !password.value.trim()) return loading.value = true error.value = '' try { - authStore.setToken(token.value.trim()) + const loginRes = await loginWithPassword(username.value.trim(), password.value) + authStore.setToken(loginRes.data.access_token) await authStore.fetchMe() const redirect = route.query.redirect || '/me' router.push(redirect) } catch (err) { authStore.logout() const detail = err.response?.data?.detail - if (detail === 'missing_bearer_token' || detail === 'invalid_bearer_token') { - error.value = 'Token 無效或已過期,請重新取得' + if (detail === 'invalid_username_or_password') { + error.value = '帳號或密碼錯誤' + } else if (detail === 'authentik_login_not_configured') { + error.value = '後端尚未設定 Authentik 登入參數' } else { error.value = '登入失敗,請稍後再試' }