fix: switch frontend login to authentik auth-code flow

This commit is contained in:
Chris
2026-03-30 01:04:28 +08:00
parent 096136e9d5
commit 42f04ef961
6 changed files with 179 additions and 58 deletions

View File

@@ -1,12 +1,20 @@
import logging
import secrets
from urllib.parse import urljoin from urllib.parse import urljoin
import httpx import httpx
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from app.core.config import get_settings from app.core.config import get_settings
from app.schemas.login import LoginRequest, LoginResponse from app.schemas.login import (
LoginRequest,
LoginResponse,
OIDCAuthUrlResponse,
OIDCCodeExchangeRequest,
)
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
logger = logging.getLogger(__name__)
def _resolve_username_by_email(settings, email: str) -> str | None: def _resolve_username_by_email(settings, email: str) -> str | None:
@@ -87,6 +95,7 @@ def login(payload: LoginRequest) -> LoginResponse:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_unreachable") from exc raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_unreachable") from exc
if resp.status_code >= 400: if resp.status_code >= 400:
logger.warning("authentik password grant failed: status=%s body=%s", resp.status_code, resp.text)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_username_or_password") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_username_or_password")
data = resp.json() data = resp.json()
@@ -100,3 +109,71 @@ def login(payload: LoginRequest) -> LoginResponse:
expires_in=data.get("expires_in"), expires_in=data.get("expires_in"),
scope=data.get("scope"), scope=data.get("scope"),
) )
@router.get("/oidc/url", response_model=OIDCAuthUrlResponse)
def get_oidc_authorize_url(redirect_uri: str) -> OIDCAuthUrlResponse:
settings = get_settings()
client_id = settings.authentik_client_id or settings.authentik_audience
if not settings.authentik_base_url or not client_id:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="authentik_login_not_configured")
authorize_endpoint = urljoin(settings.authentik_base_url.rstrip("/") + "/", "application/o/authorize/")
state = secrets.token_urlsafe(24)
params = httpx.QueryParams(
{
"client_id": client_id,
"response_type": "code",
"scope": "openid profile email",
"redirect_uri": redirect_uri,
"state": state,
"prompt": "login",
}
)
return OIDCAuthUrlResponse(authorize_url=f"{authorize_endpoint}?{params}")
@router.post("/oidc/exchange", response_model=LoginResponse)
def exchange_oidc_code(payload: OIDCCodeExchangeRequest) -> 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": "authorization_code",
"client_id": client_id,
"client_secret": settings.authentik_client_secret,
"code": payload.code,
"redirect_uri": payload.redirect_uri,
}
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:
logger.warning("authentik auth-code exchange failed: status=%s body=%s", resp.status_code, resp.text)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="authentik_code_exchange_failed")
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"),
)

View File

@@ -11,3 +11,12 @@ class LoginResponse(BaseModel):
token_type: str = "Bearer" token_type: str = "Bearer"
expires_in: int | None = None expires_in: int | None = None
scope: str | None = None scope: str | None = None
class OIDCAuthUrlResponse(BaseModel):
authorize_url: str
class OIDCCodeExchangeRequest(BaseModel):
code: str
redirect_uri: str

View File

@@ -2,3 +2,9 @@ import { userHttp } from './http'
export const loginWithPassword = (username, password) => export const loginWithPassword = (username, password) =>
userHttp.post('/auth/login', { username, password }) userHttp.post('/auth/login', { username, password })
export const getOidcAuthorizeUrl = (redirectUri) =>
userHttp.get('/auth/oidc/url', { params: { redirect_uri: redirectUri } })
export const exchangeOidcCode = (code, redirectUri) =>
userHttp.post('/auth/oidc/exchange', { code, redirect_uri: redirectUri })

View File

@@ -0,0 +1,54 @@
<template>
<div class="flex items-center justify-center min-h-[70vh]">
<el-card class="w-full max-w-md shadow-md">
<div class="text-center space-y-3">
<h1 class="text-xl font-bold text-gray-800">member.ose.tw</h1>
<p class="text-sm text-gray-500">正在處理登入結果...</p>
<el-alert
v-if="error"
:title="error"
type="error"
show-icon
:closable="false"
/>
</div>
</el-card>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { exchangeOidcCode } from '@/api/auth'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const error = ref('')
onMounted(async () => {
const code = route.query.code
const redirect = route.query.redirect || '/me'
if (!code || typeof code !== 'string') {
error.value = '缺少授權碼,請重新登入'
return
}
try {
const callbackUrl = `${window.location.origin}/auth/callback?redirect=${encodeURIComponent(redirect)}`
const res = await exchangeOidcCode(code, callbackUrl)
authStore.setToken(res.data.access_token)
await authStore.fetchMe()
router.replace(typeof redirect === 'string' ? redirect : '/me')
} catch (err) {
authStore.logout()
const detail = err.response?.data?.detail
if (detail === 'authentik_code_exchange_failed') {
error.value = '授權碼交換失敗,請重新登入'
} else {
error.value = '登入失敗,請稍後再試'
}
}
})
</script>

View File

@@ -8,82 +8,52 @@
</div> </div>
</template> </template>
<el-form @submit.prevent="handleLogin"> <el-alert
<el-form-item label="帳號"> v-if="error"
<el-input :title="error"
v-model="username" type="error"
placeholder="請輸入 Authentik username / email" show-icon
clearable :closable="false"
/> class="mb-4"
</el-form-item> />
<el-form-item label="密碼">
<el-input
v-model="password"
type="password"
placeholder="請輸入密碼"
clearable
show-password
/>
</el-form-item>
<el-alert <el-button
v-if="error" type="primary"
:title="error" class="w-full"
type="error" :loading="loading"
show-icon @click="handleOidcLogin"
:closable="false" >
class="mb-4" 前往 Authentik 登入
/> </el-button>
<el-form-item> <div class="mt-4 text-xs text-gray-400 text-center space-y-1">
<el-button <p>會跳轉到 Authentik 輸入帳號密碼成功後自動回來</p>
type="primary" <p>登入成功後 access token 會存於本機 localStorage</p>
native-type="submit" </div>
class="w-full"
:loading="loading"
:disabled="!username.trim() || !password.trim()"
>
登入
</el-button>
</el-form-item>
</el-form>
<p class="text-xs text-gray-400 text-center mt-2">登入成功後 access token 會存於本機 localStorage</p>
</el-card> </el-card>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { getOidcAuthorizeUrl } from '@/api/auth'
import { loginWithPassword } from '@/api/auth'
const router = useRouter()
const route = useRoute() const route = useRoute()
const authStore = useAuthStore()
const username = ref('')
const password = ref('')
const loading = ref(false) const loading = ref(false)
const error = ref('') const error = ref('')
async function handleLogin() { async function handleOidcLogin() {
if (!username.value.trim() || !password.value.trim()) return
loading.value = true loading.value = true
error.value = '' error.value = ''
try { try {
const loginRes = await loginWithPassword(username.value.trim(), password.value)
authStore.setToken(loginRes.data.access_token)
await authStore.fetchMe()
const redirect = route.query.redirect || '/me' const redirect = route.query.redirect || '/me'
router.push(redirect) const callbackUrl = `${window.location.origin}/auth/callback?redirect=${encodeURIComponent(redirect)}`
const res = await getOidcAuthorizeUrl(callbackUrl)
window.location.href = res.data.authorize_url
} catch (err) { } catch (err) {
authStore.logout()
const detail = err.response?.data?.detail const detail = err.response?.data?.detail
if (detail === 'invalid_username_or_password') { if (detail === 'authentik_login_not_configured') {
error.value = '帳號或密碼錯誤'
} else if (detail === 'authentik_login_not_configured') {
error.value = '後端尚未設定 Authentik 登入參數' error.value = '後端尚未設定 Authentik 登入參數'
} else { } else {
error.value = '登入失敗,請稍後再試' error.value = '登入失敗,請稍後再試'

View File

@@ -8,6 +8,11 @@ const routes = [
name: 'login', name: 'login',
component: () => import('@/pages/LoginPage.vue') component: () => import('@/pages/LoginPage.vue')
}, },
{
path: '/auth/callback',
name: 'auth-callback',
component: () => import('@/pages/AuthCallbackPage.vue')
},
{ {
path: '/me', path: '/me',
name: 'me', name: 'me',