package service import ( "crypto/rand" "crypto/rsa" "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/hex" "encoding/pem" "errors" "fmt" "net/url" "os" "path/filepath" "strconv" "strings" "sync" "time" "github.com/golang-jwt/jwt/v5" "git.iioio.com/freefire/jiang13-forum/config" "git.iioio.com/freefire/jiang13-forum/model" ) const ( oidcAuthCodeTTL = 5 * time.Minute oidcAccessTokenTTL = time.Hour oidcIDTokenTTL = time.Hour oidcRSABits = 2048 oidcKeyID = "jiang13-oidc-1" ) var ( ErrOIDCNotConfigured = errors.New("OIDC 未配置(请在管理后台启用并至少创建一个 OAuth 应用)") ErrOIDCInvalidClient = errors.New("无效的 client_id 或 client_secret") ErrOIDCInvalidRedirect = errors.New("redirect_uri 未登记") ErrOIDCInvalidRequest = errors.New("授权请求参数无效") ErrOIDCInvalidGrant = errors.New("授权码无效或已过期") ErrOIDCInvalidToken = errors.New("access_token 无效") ErrOIDCUserBanned = errors.New("账号已被禁言,无法授权") ErrOIDCPKCEFailed = errors.New("PKCE 校验失败") ErrOIDCInvalidLogout = errors.New("post_logout_redirect_uri 未登记") ) // OIDCService 论坛作为 OpenID Connect Provider type OIDCService struct { cfg *config.Config settings *ForumSettingsService mu sync.RWMutex privateKey *rsa.PrivateKey } // NewOIDCService 创建并加载/生成 RSA 密钥 func NewOIDCService(cfg *config.Config, settings *ForumSettingsService) (*OIDCService, error) { s := &OIDCService{cfg: cfg, settings: settings} if err := s.loadOrCreateKey(); err != nil { return nil, err } return s, nil } func (s *OIDCService) runtime() OIDCConfig { if s.settings != nil { return s.settings.OIDCConfig() } return OIDCConfig{} } func (s *OIDCService) loadOrCreateKey() error { keyPath := filepath.Join(s.cfg.DataDir, ".oidc_rsa.pem") if data, err := os.ReadFile(keyPath); err == nil && len(data) > 0 { block, _ := pem.Decode(data) if block == nil { return fmt.Errorf("解析 OIDC RSA 密钥失败") } key, err := x509.ParsePKCS1PrivateKey(block.Bytes) if err != nil { parsed, err2 := x509.ParsePKCS8PrivateKey(block.Bytes) if err2 != nil { return fmt.Errorf("解析 OIDC RSA 密钥失败: %w", err) } var ok bool key, ok = parsed.(*rsa.PrivateKey) if !ok { return fmt.Errorf("OIDC 密钥不是 RSA") } } s.privateKey = key return nil } key, err := rsa.GenerateKey(rand.Reader, oidcRSABits) if err != nil { return fmt.Errorf("生成 OIDC RSA 密钥失败: %w", err) } pemBytes := pem.EncodeToMemory(&pem.Block{ Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key), }) if err := os.WriteFile(keyPath, pemBytes, 0600); err != nil { return fmt.Errorf("写入 OIDC RSA 密钥失败: %w", err) } s.privateKey = key return nil } // Enabled 是否可对外提供 OIDC func (s *OIDCService) Enabled() bool { return s.runtime().Ready } // Issuer 返回 OIDC issuer func (s *OIDCService) Issuer() string { return s.runtime().RootURL } // Discovery 返回 OpenID Provider Metadata func (s *OIDCService) Discovery() (map[string]any, error) { rt := s.runtime() if !rt.Ready { return nil, ErrOIDCNotConfigured } base := rt.RootURL return map[string]any{ "issuer": base, "authorization_endpoint": base + "/oauth/authorize", "token_endpoint": base + "/oauth/token", "userinfo_endpoint": base + "/oauth/userinfo", "jwks_uri": base + "/oauth/jwks", "end_session_endpoint": base + "/oauth/logout", "response_types_supported": []string{"code"}, "subject_types_supported": []string{"public"}, "id_token_signing_alg_values_supported": []string{"RS256"}, "scopes_supported": []string{"openid", "profile", "email", "groups"}, "token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"}, "claims_supported": []string{ "sub", "name", "preferred_username", "email", "email_verified", "picture", "groups", }, "code_challenge_methods_supported": []string{"S256", "plain"}, }, nil } // JWKS 返回 JSON Web Key Set func (s *OIDCService) JWKS() (map[string]any, error) { s.mu.RLock() key := s.privateKey s.mu.RUnlock() if key == nil { return nil, ErrOIDCNotConfigured } pub := key.PublicKey return map[string]any{ "keys": []map[string]string{ { "kty": "RSA", "use": "sig", "alg": "RS256", "kid": oidcKeyID, "n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()), "e": base64.RawURLEncoding.EncodeToString(bigIntBytes(pub.E)), }, }, }, nil } func bigIntBytes(e int) []byte { if e == 0 { return []byte{0} } var b []byte for v := e; v > 0; v >>= 8 { b = append([]byte{byte(v & 0xff)}, b...) } return b } // AuthorizeRequest 授权端点查询参数 type AuthorizeRequest struct { ClientID string RedirectURI string ResponseType string Scope string State string Nonce string CodeChallenge string CodeChallengeMethod string } // ValidateAuthorize 校验授权请求(不要求已登录) func (s *OIDCService) ValidateAuthorize(req AuthorizeRequest) error { rt := s.runtime() if !rt.Ready { return ErrOIDCNotConfigured } client, err := FindEnabledOAuthClient(req.ClientID) if err != nil { return ErrOIDCInvalidClient } if req.ResponseType != "code" { return ErrOIDCInvalidRequest } if !redirectAllowed(client.RedirectURIs, req.RedirectURI) { return ErrOIDCInvalidRedirect } if !hasScope(req.Scope, "openid") { return ErrOIDCInvalidRequest } if req.CodeChallenge != "" { m := strings.ToUpper(req.CodeChallengeMethod) if m == "" { m = "PLAIN" } if m != "S256" && m != "PLAIN" { return ErrOIDCInvalidRequest } } return nil } func hasScope(scope, want string) bool { for _, p := range strings.Fields(scope) { if p == want { return true } } return false } func redirectAllowed(redirectURIsCSV, uri string) bool { for _, allowed := range splitRedirectURIs(redirectURIsCSV) { if allowed == uri { return true } } return false } // IssueAuthCode 已登录用户签发授权码,返回带 code/state 的回调 URL func (s *OIDCService) IssueAuthCode(userID uint, req AuthorizeRequest) (string, error) { if err := s.ValidateAuthorize(req); err != nil { return "", err } var user model.User if err := model.DB.First(&user, userID).Error; err != nil { return "", ErrOIDCInvalidRequest } if user.Banned { return "", ErrOIDCUserBanned } code, err := randomToken(32) if err != nil { return "", err } method := strings.ToUpper(req.CodeChallengeMethod) if req.CodeChallenge != "" && method == "" { method = "PLAIN" } rec := &model.OAuthAuthCode{ Code: code, ClientID: req.ClientID, UserID: user.ID, RedirectURI: req.RedirectURI, Scope: req.Scope, Nonce: req.Nonce, CodeChallenge: req.CodeChallenge, CodeChallengeMethod: method, ExpiresAt: time.Now().Add(oidcAuthCodeTTL), } if err := model.DB.Create(rec).Error; err != nil { return "", err } u, err := url.Parse(req.RedirectURI) if err != nil { return "", ErrOIDCInvalidRedirect } q := u.Query() q.Set("code", code) if req.State != "" { q.Set("state", req.State) } u.RawQuery = q.Encode() return u.String(), nil } // TokenRequest 换票请求 type TokenRequest struct { GrantType string Code string RedirectURI string ClientID string ClientSecret string CodeVerifier string } // TokenResponse OAuth token 响应 type TokenResponse struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` IDToken string `json:"id_token,omitempty"` Scope string `json:"scope,omitempty"` RefreshToken string `json:"refresh_token,omitempty"` } // ExchangeCode 授权码换 token func (s *OIDCService) ExchangeCode(req TokenRequest) (*TokenResponse, error) { rt := s.runtime() if !rt.Ready { return nil, ErrOIDCNotConfigured } if req.GrantType != "authorization_code" { return nil, ErrOIDCInvalidRequest } client, err := FindEnabledOAuthClient(req.ClientID) if err != nil || !VerifyOAuthClientSecret(client, req.ClientSecret) { return nil, ErrOIDCInvalidClient } var rec model.OAuthAuthCode if err := model.DB.Where("code = ?", req.Code).First(&rec).Error; err != nil { return nil, ErrOIDCInvalidGrant } if rec.Used || time.Now().After(rec.ExpiresAt) { // 重放:作废同用户同客户端未过期码 if rec.Used { _ = model.DB.Model(&model.OAuthAuthCode{}). Where("client_id = ? AND user_id = ? AND used = ? AND expires_at > ?", rec.ClientID, rec.UserID, false, time.Now()). Update("used", true).Error } return nil, ErrOIDCInvalidGrant } if rec.ClientID != req.ClientID || rec.RedirectURI != req.RedirectURI { return nil, ErrOIDCInvalidGrant } if err := verifyPKCE(rec.CodeChallenge, rec.CodeChallengeMethod, req.CodeVerifier); err != nil { return nil, err } rec.Used = true _ = model.DB.Save(&rec).Error var user model.User if err := model.DB.First(&user, rec.UserID).Error; err != nil || user.Banned { return nil, ErrOIDCInvalidGrant } access, err := s.signAccessToken(&user, rec.Scope, req.ClientID) if err != nil { return nil, err } idToken, err := s.signIDToken(&user, rec.Scope, req.ClientID, rec.Nonce) if err != nil { return nil, err } return &TokenResponse{ AccessToken: access, TokenType: "Bearer", ExpiresIn: int(oidcAccessTokenTTL.Seconds()), IDToken: idToken, Scope: rec.Scope, }, nil } func verifyPKCE(challenge, method, verifier string) error { if challenge == "" { return nil } if verifier == "" { return ErrOIDCPKCEFailed } switch strings.ToUpper(method) { case "S256": sum := sha256.Sum256([]byte(verifier)) calc := base64.RawURLEncoding.EncodeToString(sum[:]) if calc != challenge { return ErrOIDCPKCEFailed } case "PLAIN", "": if verifier != challenge { return ErrOIDCPKCEFailed } default: return ErrOIDCPKCEFailed } return nil } type oidcAccessClaims struct { Scope string `json:"scope,omitempty"` ClientID string `json:"client_id,omitempty"` jwt.RegisteredClaims } type oidcIDClaims struct { Name string `json:"name,omitempty"` PreferredUsername string `json:"preferred_username,omitempty"` Email string `json:"email,omitempty"` EmailVerified bool `json:"email_verified,omitempty"` Picture string `json:"picture,omitempty"` Groups []string `json:"groups,omitempty"` Nonce string `json:"nonce,omitempty"` jwt.RegisteredClaims } func (s *OIDCService) signAccessToken(user *model.User, scope, clientID string) (string, error) { now := time.Now() issuer := s.Issuer() claims := oidcAccessClaims{ Scope: scope, ClientID: clientID, RegisteredClaims: jwt.RegisteredClaims{ Issuer: issuer, Subject: strconv.FormatUint(uint64(user.ID), 10), Audience: []string{clientID}, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(oidcAccessTokenTTL)), }, } t := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) t.Header["kid"] = oidcKeyID s.mu.RLock() defer s.mu.RUnlock() return t.SignedString(s.privateKey) } func (s *OIDCService) signIDToken(user *model.User, scope, clientID, nonce string) (string, error) { now := time.Now() issuer := s.Issuer() claims := oidcIDClaims{ Nonce: nonce, Groups: s.userGroups(user), RegisteredClaims: jwt.RegisteredClaims{ Issuer: issuer, Subject: strconv.FormatUint(uint64(user.ID), 10), Audience: []string{clientID}, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(oidcIDTokenTTL)), }, } if hasScope(scope, "profile") || scope == "" || hasScope(scope, "openid") { claims.Name = user.Nickname if claims.Name == "" { claims.Name = user.Username } claims.PreferredUsername = user.Username claims.Picture = s.absoluteURL(user.Avatar) } if hasScope(scope, "email") || hasScope(scope, "openid") { claims.Email = user.Email claims.EmailVerified = user.Email != "" } t := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) t.Header["kid"] = oidcKeyID s.mu.RLock() defer s.mu.RUnlock() return t.SignedString(s.privateKey) } func (s *OIDCService) userGroups(user *model.User) []string { rt := s.runtime() groups := make([]string, 0, 2) if rt.UserGroup != "" { groups = append(groups, rt.UserGroup) } if user.Role == model.RoleAdmin && rt.AdminGroup != "" { groups = append(groups, rt.AdminGroup) } return groups } // UserInfo 根据 access_token 返回用户声明 func (s *OIDCService) UserInfo(accessToken string) (map[string]any, error) { claims, err := s.parseAccessToken(accessToken) if err != nil { return nil, err } uid, err := strconv.ParseUint(claims.Subject, 10, 64) if err != nil { return nil, ErrOIDCInvalidToken } var user model.User if err := model.DB.First(&user, uint(uid)).Error; err != nil || user.Banned { return nil, ErrOIDCInvalidToken } rt := s.runtime() out := map[string]any{ "sub": strconv.FormatUint(uint64(user.ID), 10), } if hasScope(claims.Scope, "profile") || claims.Scope == "" { name := user.Nickname if name == "" { name = user.Username } out["name"] = name out["preferred_username"] = user.Username if pic := s.absoluteURL(user.Avatar); pic != "" { out["picture"] = pic } } if hasScope(claims.Scope, "email") || hasScope(claims.Scope, "openid") { if user.Email != "" { out["email"] = user.Email out["email_verified"] = true } } if _, ok := out["preferred_username"]; !ok { out["preferred_username"] = user.Username out["name"] = user.Nickname if out["name"] == "" { out["name"] = user.Username } } groups := s.userGroups(&user) if len(groups) > 0 { claim := rt.GroupClaim if claim == "" { claim = "groups" } out[claim] = groups if claim != "groups" { out["groups"] = groups } } return out, nil } // ResolveLogoutRedirect 校验并返回登出后跳转地址(空表示回首页) func (s *OIDCService) ResolveLogoutRedirect(postLogoutRedirectURI, state string) (string, error) { uri := strings.TrimSpace(postLogoutRedirectURI) if uri == "" { return "/", nil } var clients []model.OAuthClient if err := model.DB.Where("enabled = ?", true).Find(&clients).Error; err != nil { return "", err } allowed := false for _, c := range clients { if redirectAllowed(c.RedirectURIs, uri) { allowed = true break } // 允许同 host 下任意已登记前缀的登出回调(Gitea 常用 / 根路径) for _, reg := range splitRedirectURIs(c.RedirectURIs) { if sameOrigin(reg, uri) { allowed = true break } } if allowed { break } } if !allowed { return "", ErrOIDCInvalidLogout } u, err := url.Parse(uri) if err != nil { return "", ErrOIDCInvalidLogout } if state != "" { q := u.Query() q.Set("state", state) u.RawQuery = q.Encode() } return u.String(), nil } func sameOrigin(a, b string) bool { ua, err1 := url.Parse(a) ub, err2 := url.Parse(b) if err1 != nil || err2 != nil { return false } return strings.EqualFold(ua.Scheme, ub.Scheme) && strings.EqualFold(ua.Host, ub.Host) } func (s *OIDCService) parseAccessToken(tokenStr string) (*oidcAccessClaims, error) { s.mu.RLock() key := s.privateKey s.mu.RUnlock() if key == nil { return nil, ErrOIDCNotConfigured } tok, err := jwt.ParseWithClaims(tokenStr, &oidcAccessClaims{}, func(t *jwt.Token) (any, error) { if t.Method != jwt.SigningMethodRS256 { return nil, ErrOIDCInvalidToken } return &key.PublicKey, nil }) if err != nil || !tok.Valid { return nil, ErrOIDCInvalidToken } claims, ok := tok.Claims.(*oidcAccessClaims) if !ok { return nil, ErrOIDCInvalidToken } if claims.Issuer != s.Issuer() { return nil, ErrOIDCInvalidToken } return claims, nil } func (s *OIDCService) absoluteURL(path string) string { path = strings.TrimSpace(path) if path == "" { return "" } if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { return path } if !strings.HasPrefix(path, "/") { path = "/" + path } base := s.Issuer() if base == "" { return path } return base + path } func randomToken(nBytes int) (string, error) { b := make([]byte, nBytes) if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil }