Files
2026-10-03 10:44:29 +08:00

346 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package oidc
import (
"crypto/sha256"
"encoding/base64"
"errors"
"log"
"net/http"
"net/url"
"strings"
"time"
"gorm.io/gorm"
"alterminal/internal/application"
"alterminal/internal/auth"
)
// tokenResponse 為 token 端點的成功回應(RFC 6749 §5.1;ID token 與
// refresh token 僅在對應條件成立時出現——ID token 於簽發對象為使用者
// 且 scope 含 openid 時、refresh token 於 scope 含 offline_access 時,
// OIDC Core §3.1.3.3)。
type tokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
Scope string `json:"scope"`
IDToken string `json:"id_token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
}
// tokenError 為 RFC 6749 §5.2 的錯誤回應格式。
type tokenError struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description,omitempty"`
}
// writeTokenError 輸出 token 端點錯誤;client 認證失敗(invalid_client)
// 回 401,其餘依規格回 400。
func writeTokenError(w http.ResponseWriter, status int, code, description string) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
if status == http.StatusUnauthorized {
// 以 Basic 認證的請求須提示 Basic(RFC 6749 §5.2),一律附上不影響。
w.Header().Set("WWW-Authenticate", `Basic realm="alterminal"`)
}
auth.WriteJSON(w, status, tokenError{Error: code, ErrorDescription: description})
}
// TokenHandler 處理 POST /token(RFC 6749 §3.2):以授權碼(§4.1.3)
// 或 refresh token(§6)換發 Access/ID/Refresh Token。
func TokenHandler(db *gorm.DB, issuer string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
writeTokenError(w, http.StatusMethodNotAllowed, "invalid_request", "僅支援 POST")
return
}
if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/x-www-form-urlencoded") {
writeTokenError(w, http.StatusBadRequest, "invalid_request", "Content-Type 須為 application/x-www-form-urlencoded")
return
}
if err := r.ParseForm(); err != nil {
writeTokenError(w, http.StatusBadRequest, "invalid_request", "無法解析表單內容")
return
}
app := authenticateTokenClient(db, w, r)
if app == nil {
return
}
switch r.PostFormValue("grant_type") {
case "authorization_code":
tokenAuthorizationCode(db, issuer, w, r, app)
case "refresh_token":
tokenRefreshToken(db, issuer, w, r, app)
case "":
writeTokenError(w, http.StatusBadRequest, "invalid_request", "缺少 grant_type")
default:
writeTokenError(w, http.StatusBadRequest, "unsupported_grant_type", "不支援的 grant_type")
}
}
}
// authenticateTokenClient 驗證 Client 身分(RFC 6749 §2.3.1):client
// 認證可經 HTTP Basic(推薦)或表單欄位,兩處同時出現的 client_id 必須
// 一致(§3.2.1)。機密式 Client 必須提出正確的 client secret;公開式
// Client 不持有 secret,僅以 client_id 識別(PKCE 承擔防護)。驗證失敗
// 已寫出 401 回應並回傳 nil。
func authenticateTokenClient(db *gorm.DB, w http.ResponseWriter, r *http.Request) *application.Application {
basicID, basicSecret, hasBasic := r.BasicAuth()
postID := r.PostFormValue("client_id")
postSecret := r.PostFormValue("client_secret")
// RFC 6749 §2.3.1 要求 Basic 中的 client_id/secret 先以表單編碼;
// 本服務產生的識別值僅含 base64url 字元,解碼失敗時退回原值以相容
// 未編碼的實作。
if basicID != "" {
if unescaped, err := url.QueryUnescape(basicID); err == nil {
basicID = unescaped
}
if unescaped, err := url.QueryUnescape(basicSecret); err == nil {
basicSecret = unescaped
}
}
if hasBasic && postID != "" && basicID != postID {
writeTokenError(w, http.StatusBadRequest, "invalid_request", "Basic 與表單的 client_id 不一致")
return nil
}
clientID := postID
if clientID == "" {
clientID = basicID
}
if clientID == "" {
writeTokenError(w, http.StatusUnauthorized, "invalid_client", "缺少 client_id")
return nil
}
app, err := application.GetByClientID(db, clientID)
if err != nil {
// 查無 client 或查詢失敗一律 401,不洩漏 client 是否存在。
if !errors.Is(err, gorm.ErrRecordNotFound) {
log.Printf("token: %v", err)
}
writeTokenError(w, http.StatusUnauthorized, "invalid_client", "client 認證失敗")
return nil
}
secret := postSecret
if hasBasic && secret == "" {
secret = basicSecret
}
if !app.IsPublic() && !app.CheckSecret(secret) {
writeTokenError(w, http.StatusUnauthorized, "invalid_client", "client 認證失敗")
return nil
}
return app
}
// tokenAuthorizationCode 處理 grant_type=authorization_code(RFC 6749
// §4.1.3):兌換一次性授權碼,逐項比對兌換條件後簽發權杖。
func tokenAuthorizationCode(db *gorm.DB, issuer string, w http.ResponseWriter, r *http.Request, app *application.Application) {
if !app.GrantTypes.Contains(application.GrantAuthorizationCode) {
writeTokenError(w, http.StatusBadRequest, "unauthorized_client", "應用程式未啟用授權碼流程")
return
}
code := r.PostFormValue("code")
if code == "" {
writeTokenError(w, http.StatusBadRequest, "invalid_request", "缺少 code")
return
}
ac, err := GetAuthorizationCode(db, code)
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
log.Printf("token: %v", err)
}
writeTokenError(w, http.StatusBadRequest, "invalid_grant", "授權碼無效")
return
}
if ac.UsedAt != nil {
// 授權碼重用:撤銷其發行的一切 refresh token(RFC 6749 §4.1.2
// 一次性要求;OAuth 2.0 Security BCP §4.5.3.3 的防護)。
if err := RevokeRefreshTokensByAuthorization(db, ac.ID); err != nil {
log.Printf("token: %v", err)
}
writeTokenError(w, http.StatusBadRequest, "invalid_grant", "授權碼無效")
return
}
if ac.ExpiresAt.Before(time.Now()) {
writeTokenError(w, http.StatusBadRequest, "invalid_grant", "授權碼已過期")
return
}
// 授權碼與 client 及 redirect_uri 的綁定逐項比對(RFC 6749 §4.1.3);
// 不符一律回 invalid_grant,不洩漏原因。
if ac.ApplicationID != app.ID || r.PostFormValue("redirect_uri") != ac.RedirectURI {
writeTokenError(w, http.StatusBadRequest, "invalid_grant", "授權碼無效")
return
}
// PKCE(RFC 7636 §4.6):發碼時有 challenge 者,兌換必須提出比對
// 相符的 code_verifier。
if ac.CodeChallenge != "" {
verifier := r.PostFormValue("code_verifier")
if !validCodeVerifier(verifier) {
writeTokenError(w, http.StatusBadRequest, "invalid_request", "code_verifier 格式無效")
return
}
if pkceChallenge(verifier) != ac.CodeChallenge {
writeTokenError(w, http.StatusBadRequest, "invalid_grant", "PKCE 驗證失敗")
return
}
}
ok, err := ConsumeAuthorizationCode(db, ac.ID)
if err != nil {
log.Printf("token: %v", err)
writeTokenError(w, http.StatusInternalServerError, "", "")
return
}
if !ok {
// 並發兌換的輸家;勝者已完成撤銷防護,比照重用處理。
writeTokenError(w, http.StatusBadRequest, "invalid_grant", "授權碼無效")
return
}
var u auth.User
if err := db.First(&u, ac.UserID).Error; err != nil {
log.Printf("token: 查詢使用者 %d: %v", ac.UserID, err)
writeTokenError(w, http.StatusInternalServerError, "", "")
return
}
issueTokenResponse(db, issuer, w, &u, app, ac.Scope, ac.Nonce, ac.ID, ac.AuthTime)
}
// tokenRefreshToken 處理 grant_type=refresh_token(RFC 6749 §6):以
// refresh token 換發新權杖組,舊 token 立即輪替作廢;偵測到重用已輪替
// 的 token 時撤銷該使用者於該應用程式的全部 refresh token。
func tokenRefreshToken(db *gorm.DB, issuer string, w http.ResponseWriter, r *http.Request, app *application.Application) {
if !app.GrantTypes.Contains(application.GrantRefreshToken) {
writeTokenError(w, http.StatusBadRequest, "unauthorized_client", "應用程式未啟用 refresh_token")
return
}
token := r.PostFormValue("refresh_token")
if token == "" {
writeTokenError(w, http.StatusBadRequest, "invalid_request", "缺少 refresh_token")
return
}
rt, err := GetRefreshToken(db, token)
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
log.Printf("token: %v", err)
}
writeTokenError(w, http.StatusBadRequest, "invalid_grant", "refresh token 無效")
return
}
if rt.RotatedAt != nil {
// 已輪替的 token 再次出現即為重用:整鏈撤銷(OAuth 2.0
// Security BCP §4.14.2)。
if err := RevokeRefreshTokensFor(db, rt.UserID, rt.ApplicationID); err != nil {
log.Printf("token: %v", err)
}
writeTokenError(w, http.StatusBadRequest, "invalid_grant", "refresh token 重用,相關權杖已撤銷")
return
}
if rt.RevokedAt != nil || rt.ExpiresAt.Before(time.Now()) {
writeTokenError(w, http.StatusBadRequest, "invalid_grant", "refresh token 已失效")
return
}
if rt.ApplicationID != app.ID {
writeTokenError(w, http.StatusBadRequest, "invalid_grant", "refresh token 無效")
return
}
// RFC 6749 §6:請求可縮小 scope,不可擴大。
scope := rt.Scope
if req := strings.TrimSpace(r.PostFormValue("scope")); req != "" {
if !scopeCovered(rt.Scope, req) {
writeTokenError(w, http.StatusBadRequest, "invalid_scope", "請求的 scope 超出原授權範圍")
return
}
scope = strings.Join(normalizeScope(req), " ")
}
ok, err := RotateRefreshToken(db, rt.ID)
if err != nil {
log.Printf("token: %v", err)
writeTokenError(w, http.StatusInternalServerError, "", "")
return
}
if !ok {
writeTokenError(w, http.StatusBadRequest, "invalid_grant", "refresh token 無效")
return
}
var u auth.User
if err := db.First(&u, rt.UserID).Error; err != nil {
log.Printf("token: 查詢使用者 %d: %v", rt.UserID, err)
writeTokenError(w, http.StatusInternalServerError, "", "")
return
}
issueTokenResponse(db, issuer, w, &u, app, scope, "", rt.AuthorizationID, rt.AuthTime)
}
// issueTokenResponse 簽發權杖組並寫出成功回應:Access Token 必發;scope
// 含 openid 時簽發 ID token;scope 含 offline_access 時簽發 refresh
// token 並作廢舊授權碼鏈的後繼(由輪替模型保證單一現行 token)。
// authorizationID 為本次授權鏈的源頭授權碼 ID,refresh token 沿用記錄。
func issueTokenResponse(db *gorm.DB, issuer string, w http.ResponseWriter, u *auth.User, app *application.Application, scope, nonce string, authorizationID uint, authTime time.Time) {
access, err := IssueAccessToken(db, issuer, u.ID, app, scope)
if err != nil {
log.Printf("token: %v", err)
writeTokenError(w, http.StatusInternalServerError, "", "")
return
}
resp := tokenResponse{
AccessToken: access,
TokenType: "Bearer",
ExpiresIn: int64(accessTokenTTL.Seconds()),
Scope: scope,
}
if scopeHas(scope, "openid") {
idToken, err := IssueIDToken(db, issuer, u, app, scope, nonce, authTime)
if err != nil {
log.Printf("token: %v", err)
writeTokenError(w, http.StatusInternalServerError, "", "")
return
}
resp.IDToken = idToken
}
if scopeHas(scope, "offline_access") {
_, plain, err := NewRefreshToken(db, app.ID, u.ID, authorizationID, scope, authTime)
if err != nil {
log.Printf("token: %v", err)
writeTokenError(w, http.StatusInternalServerError, "", "")
return
}
resp.RefreshToken = plain
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
auth.WriteJSON(w, http.StatusOK, resp)
}
// validCodeVerifier 檢查 code_verifier 格式(RFC 7636 §4.1):
// 43–128 個字元,僅含 [A-Za-z0-9-._~]。
func validCodeVerifier(v string) bool {
if len(v) < 43 || len(v) > 128 {
return false
}
for _, c := range v {
switch {
case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9':
case c == '-' || c == '.' || c == '_' || c == '~':
default:
return false
}
}
return true
}
// pkceChallenge 計算 code_verifier 的 S256 challenge(RFC 7636 §4.2):
// BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))。
func pkceChallenge(verifier string) string {
sum := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(sum[:])
}