forked from alterminal/alterminal
346 lines
12 KiB
Go
346 lines
12 KiB
Go
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[:])
|
||
}
|