feat: add OIDC login support

- Introduced OIDC configuration options in config.example.yaml.
- Added OIDC client initialization and routes for OIDC login, callback, and exchange.
- Implemented OIDC login service to handle user authentication via OIDC.
- Created frontend components for OIDC login and callback handling.
- Updated user creation logic to support OIDC users and their identities.
- Enhanced error handling for OIDC login processes.
This commit is contained in:
mlogclub
2026-05-24 20:49:10 +08:00
parent 1dd9c835ed
commit eac039bba4
18 changed files with 870 additions and 6 deletions
+16
View File
@@ -58,6 +58,22 @@ mcp:
timeoutMs: 15000
headers: {}
oidc:
# 是否启用 OIDC 登录;本系统作为 OIDC Client。
enabled: false
# OIDC Provider issuer,例如 https://idp.example.com/realms/demo。
issuer: ""
clientId: ""
clientSecret: ""
# 必须与 OIDC Provider 后台登记的 redirect_uri 完全一致。
redirectUrl: "http://127.0.0.1:8083/api/auth/oidc_callback"
# OIDC 登录 state 签名密钥;留空时退回使用 clientSecret。
stateSecret: ""
scopes:
- openid
- profile
- email
wxWork:
# 是否启用企业微信能力。
# false 时不会初始化企业微信 SDK,登录、客服回调、应用通知都不可用。
+2
View File
@@ -47,6 +47,7 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.15 // indirect
github.com/coreos/go-oidc/v3 v3.18.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/evanphx/json-patch v0.5.2 // indirect
@@ -54,6 +55,7 @@ require (
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-redis/redis/v8 v8.11.5 // indirect
+4
View File
@@ -42,6 +42,8 @@ github.com/cloudwego/eino-ext/components/model/openai v0.1.11 h1:juf9kECfmxJBA0r
github.com/cloudwego/eino-ext/components/model/openai v0.1.11/go.mod h1:DBk44Dq1mhuoAacdUzzhZhSGeeBECDI2rIZnJFeVZoE=
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.15 h1:LbdSG9+qWzzp9RFW6dSFkaUW171JvCoYn/K63zX6dQE=
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.15/go.mod h1:p+l0zBB0GjjX8HTlbTs3g3KfUFwZC11bsCGZOXW/3L0=
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -74,6 +76,8 @@ github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GM
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI=
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+6
View File
@@ -1,7 +1,9 @@
package bootstrap
import (
"context"
"cs-agent/internal/ai/rag/vectordb"
"cs-agent/internal/oidcclient"
"cs-agent/internal/pkg/config"
"cs-agent/internal/pkg/logx"
"cs-agent/internal/services/cronx"
@@ -42,5 +44,9 @@ func Init(configPath string) error {
cronx.Init()
wxwork.Init()
if err := oidcclient.Init(context.Background()); err != nil {
slog.Error("init oidc failed", "error", err)
return err
}
return nil
}
+3
View File
@@ -16,6 +16,9 @@ func registerApiAuthRoutes(group *gin.RouterGroup) {
group.POST("/wxwork_exchange", api.WxWorkExchange)
group.GET("/wxwork_login", api.WxWorkLogin)
group.GET("/wxwork_qr_login", api.WxWorkQRLogin)
group.GET("/oidc_callback", api.OIDCCallback)
group.POST("/oidc_exchange", api.OIDCExchange)
group.GET("/oidc_login", api.OIDCLogin)
}
func registerApiChannelRoutes(group *gin.RouterGroup) {
+3
View File
@@ -31,6 +31,9 @@ func TestNewServerRegistersGinRoutes(t *testing.T) {
expected := []string{
http.MethodPost + " /api/auth/login",
http.MethodGet + " /api/auth/oidc_login",
http.MethodGet + " /api/auth/oidc_callback",
http.MethodPost + " /api/auth/oidc_exchange",
http.MethodGet + " /api/auth/profile",
http.MethodGet + " /api/dashboard/user/list",
http.MethodGet + " /api/dashboard/user/:id",
+44
View File
@@ -77,6 +77,46 @@ func WxWorkExchange(ctx *gin.Context) {
httpx.WriteJSON(ctx, ret)
}
func OIDCLogin(ctx *gin.Context) {
loginURL, err := services.OIDCLoginService.BuildOIDCLoginURL(ctx.Query("next"))
if err != nil {
ctx.Redirect(http.StatusFound, "/dashboard/login?oidcError="+url.QueryEscape(loginErrorMessage(err.Error())))
return
}
ctx.Redirect(http.StatusFound, loginURL)
}
func OIDCCallback(ctx *gin.Context) {
cfg := config.Current()
ticket, next, err := services.OIDCLoginService.LoginByOIDC(
ctx.Request.Context(),
ctx.Query("code"),
ctx.Query("state"),
cfg.Auth,
ctx.ClientIP(),
ctx.GetHeader("User-Agent"),
)
if err != nil {
ctx.Redirect(http.StatusFound, "/dashboard/login?oidcError="+url.QueryEscape(loginErrorMessage(err.Error())))
return
}
ctx.Redirect(http.StatusFound, "/dashboard/login/oidc/callback?ticket="+url.QueryEscape(ticket)+"&next="+url.QueryEscape(next))
}
func OIDCExchange(ctx *gin.Context) {
req := request.OIDCExchangeRequest{}
if err := params.ReadJSON(ctx, &req); err != nil {
httpx.WriteJSON(ctx, err)
return
}
ret, err := services.OIDCLoginService.ExchangeOIDCLoginTicket(req.Ticket)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, ret)
}
func Logout(ctx *gin.Context) {
if err := services.AuthService.Logout(ctx.GetHeader("Authorization")); err != nil {
httpx.WriteJSON(ctx, err)
@@ -95,6 +135,10 @@ func Profile(ctx *gin.Context) {
}
func wxWorkErrorMessage(message string) string {
return loginErrorMessage(message)
}
func loginErrorMessage(message string) string {
if idx := strings.Index(message, ": "); idx >= 0 {
message = message[idx+2:]
}
+332
View File
@@ -0,0 +1,332 @@
package oidcclient
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"cs-agent/internal/pkg/config"
"cs-agent/internal/pkg/dto/response"
"cs-agent/internal/pkg/errorsx"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
gooidc "github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
const (
StateTTL = 5 * time.Minute
LoginTicketTTL = 1 * time.Minute
defaultLoginNextPath = "/dashboard"
)
var (
oidcCfg config.OIDCConfig
provider *gooidc.Provider
oauthConfig *oauth2.Config
idTokenVerifier *gooidc.IDTokenVerifier
loginTicketStore sync.Map
)
type Profile struct {
Subject string `json:"sub"`
Email string `json:"email,omitempty"`
PreferredUsername string `json:"preferred_username,omitempty"`
Name string `json:"name,omitempty"`
Picture string `json:"picture,omitempty"`
RawProfile string `json:"-"`
}
type statePayload struct {
Next string `json:"next"`
Nonce string `json:"nonce"`
ExpiredAt int64 `json:"expiredAt"`
}
type loginTicket struct {
Response *response.LoginResponse
ExpiredAt time.Time
}
func Init(ctx context.Context) error {
provider = nil
oauthConfig = nil
idTokenVerifier = nil
oidcCfg = config.OIDCConfig{}
cfg := config.Current().OIDC
if !cfg.Enabled {
return nil
}
oidcCfg = cfg
if strings.TrimSpace(cfg.Issuer) == "" {
return fmt.Errorf("OIDC issuer 未配置")
}
if strings.TrimSpace(cfg.ClientID) == "" {
return fmt.Errorf("OIDC clientId 未配置")
}
if strings.TrimSpace(cfg.ClientSecret) == "" {
return fmt.Errorf("OIDC clientSecret 未配置")
}
if strings.TrimSpace(cfg.RedirectURL) == "" {
return fmt.Errorf("OIDC redirectUrl 未配置")
}
p, err := gooidc.NewProvider(ctx, strings.TrimSpace(cfg.Issuer))
if err != nil {
return err
}
scopes := cfg.Scopes
if len(scopes) == 0 {
scopes = []string{gooidc.ScopeOpenID, "profile", "email"}
}
provider = p
oauthConfig = &oauth2.Config{
ClientID: strings.TrimSpace(cfg.ClientID),
ClientSecret: strings.TrimSpace(cfg.ClientSecret),
Endpoint: p.Endpoint(),
RedirectURL: strings.TrimSpace(cfg.RedirectURL),
Scopes: scopes,
}
idTokenVerifier = p.Verifier(&gooidc.Config{ClientID: strings.TrimSpace(cfg.ClientID)})
return nil
}
func Enabled() bool {
return oidcCfg.Enabled && provider != nil && oauthConfig != nil && idTokenVerifier != nil
}
func BuildAuthCodeURL(next string) (string, error) {
if !Enabled() {
return "", errorsx.BusinessError(1, "OIDC 登录未启用")
}
state, err := CreateState(next)
if err != nil {
return "", err
}
return oauthConfig.AuthCodeURL(state), nil
}
func ExchangeCode(ctx context.Context, code string) (*Profile, error) {
if !Enabled() {
return nil, errorsx.BusinessError(1, "OIDC 登录未启用")
}
code = strings.TrimSpace(code)
if code == "" {
return nil, errorsx.InvalidParam("OIDC 授权 code 不能为空")
}
token, err := oauthConfig.Exchange(ctx, code)
if err != nil {
return nil, err
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok || strings.TrimSpace(rawIDToken) == "" {
return nil, errorsx.Unauthorized("OIDC id_token 不存在")
}
idToken, err := idTokenVerifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, err
}
profile, err := profileFromIDToken(idToken)
if err != nil {
return nil, err
}
userInfo, err := provider.UserInfo(ctx, oauth2.StaticTokenSource(token))
if err == nil && userInfo != nil && strings.TrimSpace(userInfo.Subject) == profile.Subject {
if mergedProfile, mergeErr := profileFromUserInfo(userInfo, profile); mergeErr == nil {
profile = mergedProfile
}
}
return profile, nil
}
func CreateState(next string) (string, error) {
secret := stateSecret()
if secret == "" {
return "", errorsx.BusinessError(2, "OIDC 登录密钥未配置")
}
nonce, err := randomToken("os_")
if err != nil {
return "", err
}
payload := statePayload{
Next: sanitizeNextPath(next),
Nonce: nonce,
ExpiredAt: time.Now().Add(StateTTL).Unix(),
}
body, err := json.Marshal(payload)
if err != nil {
return "", err
}
encoded := base64.RawURLEncoding.EncodeToString(body)
return encoded + "." + signState(encoded, secret), nil
}
func ParseState(state string) (string, error) {
secret := stateSecret()
if secret == "" {
return "", errorsx.Unauthorized("OIDC 登录状态无效或已过期")
}
parts := strings.Split(strings.TrimSpace(state), ".")
if len(parts) != 2 {
return "", errorsx.Unauthorized("OIDC 登录状态无效或已过期")
}
if !hmac.Equal([]byte(parts[1]), []byte(signState(parts[0], secret))) {
return "", errorsx.Unauthorized("OIDC 登录状态无效或已过期")
}
body, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return "", errorsx.Unauthorized("OIDC 登录状态无效或已过期")
}
payload := statePayload{}
if err = json.Unmarshal(body, &payload); err != nil {
return "", errorsx.Unauthorized("OIDC 登录状态无效或已过期")
}
if payload.ExpiredAt <= time.Now().Unix() {
return "", errorsx.Unauthorized("OIDC 登录状态无效或已过期")
}
return sanitizeNextPath(payload.Next), nil
}
func IssueLoginTicket(loginResp *response.LoginResponse) (string, error) {
if loginResp == nil {
return "", fmt.Errorf("登录结果不能为空")
}
ticket, err := randomToken("olt_")
if err != nil {
return "", err
}
cleanupExpiredLoginTickets()
loginTicketStore.Store(ticket, loginTicket{
Response: loginResp,
ExpiredAt: time.Now().Add(LoginTicketTTL),
})
return ticket, nil
}
func ConsumeLoginTicket(ticket string) (*response.LoginResponse, error) {
ticket = strings.TrimSpace(ticket)
if ticket == "" {
return nil, errorsx.InvalidParam("ticket 不能为空")
}
value, ok := loginTicketStore.LoadAndDelete(ticket)
if !ok {
return nil, errorsx.Unauthorized("登录票据无效或已过期")
}
record, ok := value.(loginTicket)
if !ok || record.Response == nil || time.Now().After(record.ExpiredAt) {
return nil, errorsx.Unauthorized("登录票据无效或已过期")
}
return record.Response, nil
}
func profileFromIDToken(idToken *gooidc.IDToken) (*Profile, error) {
var claims map[string]any
if err := idToken.Claims(&claims); err != nil {
return nil, err
}
raw, _ := json.Marshal(claims)
profile := &Profile{
Subject: claimString(claims, "sub"),
Email: claimString(claims, "email"),
PreferredUsername: claimString(claims, "preferred_username"),
Name: claimString(claims, "name"),
Picture: claimString(claims, "picture"),
RawProfile: string(raw),
}
if strings.TrimSpace(profile.Subject) == "" {
return nil, errorsx.Unauthorized("OIDC 用户标识不存在")
}
return profile, nil
}
func profileFromUserInfo(userInfo *gooidc.UserInfo, fallback *Profile) (*Profile, error) {
var claims map[string]any
if err := userInfo.Claims(&claims); err != nil {
return nil, err
}
raw, _ := json.Marshal(claims)
profile := &Profile{
Subject: strings.TrimSpace(userInfo.Subject),
Email: claimString(claims, "email"),
PreferredUsername: claimString(claims, "preferred_username"),
Name: claimString(claims, "name"),
Picture: claimString(claims, "picture"),
RawProfile: string(raw),
}
if fallback != nil {
profile.Email = firstNonEmpty(profile.Email, fallback.Email)
profile.PreferredUsername = firstNonEmpty(profile.PreferredUsername, fallback.PreferredUsername)
profile.Name = firstNonEmpty(profile.Name, fallback.Name)
profile.Picture = firstNonEmpty(profile.Picture, fallback.Picture)
}
if profile.RawProfile == "" {
if fallback != nil {
profile.RawProfile = fallback.RawProfile
}
}
if strings.TrimSpace(profile.Subject) == "" {
return nil, errorsx.Unauthorized("OIDC 用户标识不存在")
}
return profile, nil
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
func claimString(claims map[string]any, key string) string {
value, _ := claims[key].(string)
return strings.TrimSpace(value)
}
func stateSecret() string {
if strings.TrimSpace(oidcCfg.StateSecret) != "" {
return strings.TrimSpace(oidcCfg.StateSecret)
}
return strings.TrimSpace(oidcCfg.ClientSecret)
}
func signState(content, secret string) string {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(content))
return hex.EncodeToString(mac.Sum(nil))
}
func cleanupExpiredLoginTickets() {
now := time.Now()
loginTicketStore.Range(func(key, value any) bool {
record, ok := value.(loginTicket)
if !ok || now.After(record.ExpiredAt) {
loginTicketStore.Delete(key)
}
return true
})
}
func sanitizeNextPath(next string) string {
next = strings.TrimSpace(next)
if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
return defaultLoginNextPath
}
return next
}
func randomToken(prefix string) (string, error) {
buf := make([]byte, 24)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return prefix + hex.EncodeToString(buf), nil
}
+11
View File
@@ -17,6 +17,7 @@ type Config struct {
VectorDB VectorDBConfig `yaml:"vectorDB"`
MCP MCPConfig `yaml:"mcp"`
WxWork WxWorkConfig `yaml:"wxWork"`
OIDC OIDCConfig `yaml:"oidc"`
CustomerSession CustomerSessionConfig `yaml:"customerSession"`
}
@@ -135,6 +136,16 @@ type MCPServerConfig struct {
Headers map[string]string `yaml:"headers"`
}
type OIDCConfig struct {
Enabled bool `yaml:"enabled"`
Issuer string `yaml:"issuer"`
ClientID string `yaml:"clientId"`
ClientSecret string `yaml:"clientSecret"`
RedirectURL string `yaml:"redirectUrl"`
StateSecret string `yaml:"stateSecret"`
Scopes []string `yaml:"scopes"`
}
// WxWorkConfig 定义企业微信接入配置。
//
// 当前主要用于后台管理台的企业微信登录流程:
+4
View File
@@ -8,3 +8,7 @@ type LoginRequest struct {
type WxWorkExchangeRequest struct {
Ticket string `json:"ticket"`
}
type OIDCExchangeRequest struct {
Ticket string `json:"ticket"`
}
+3 -1
View File
@@ -63,13 +63,15 @@ type ThirdProvider string
const (
ThirdProviderWxWork ThirdProvider = "wxwork"
ThirdProviderDingtalk ThirdProvider = "dingtalk"
ThirdProviderOIDC ThirdProvider = "oidc"
)
var ThirdProviderValues = []ThirdProvider{ThirdProviderWxWork, ThirdProviderDingtalk}
var ThirdProviderValues = []ThirdProvider{ThirdProviderWxWork, ThirdProviderDingtalk, ThirdProviderOIDC}
var thirdProviderLabelMap = map[ThirdProvider]string{
ThirdProviderWxWork: "企业微信",
ThirdProviderDingtalk: "钉钉",
ThirdProviderOIDC: "OIDC",
}
func GetThirdProviderLabel(provider ThirdProvider) string {
+1
View File
@@ -381,6 +381,7 @@ func setupAuthServiceTestDB(t *testing.T) *gorm.DB {
}
if err := db.AutoMigrate(
&models.User{},
&models.UserIdentity{},
&models.Role{},
&models.Permission{},
&models.UserRole{},
+245
View File
@@ -0,0 +1,245 @@
package services
import (
"context"
"crypto/sha256"
"cs-agent/internal/models"
"cs-agent/internal/oidcclient"
"cs-agent/internal/pkg/config"
"cs-agent/internal/pkg/dto/response"
"cs-agent/internal/pkg/enums"
"cs-agent/internal/pkg/errorsx"
"cs-agent/internal/repositories"
"encoding/hex"
"strconv"
"strings"
"time"
"unicode"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
)
var OIDCLoginService = newOIDCLoginService()
type oidcLoginService struct {
}
type oidcLoginProfile = oidcclient.Profile
func newOIDCLoginService() *oidcLoginService {
return &oidcLoginService{}
}
func (s *oidcLoginService) BuildOIDCLoginURL(next string) (string, error) {
return oidcclient.BuildAuthCodeURL(next)
}
func (s *oidcLoginService) LoginByOIDC(ctx context.Context, code, state string, authCfg config.AuthConfig, clientIP, userAgent string) (string, string, error) {
next, err := oidcclient.ParseState(state)
if err != nil {
return "", "", err
}
profile, err := oidcclient.ExchangeCode(ctx, code)
if err != nil {
return "", "", err
}
loginResp, err := s.loginWithOIDCProfile(profile, authCfg, clientIP, userAgent)
if err != nil {
return "", "", err
}
ticket, err := oidcclient.IssueLoginTicket(loginResp)
if err != nil {
return "", "", err
}
return ticket, next, nil
}
func (s *oidcLoginService) ExchangeOIDCLoginTicket(ticket string) (*response.LoginResponse, error) {
return oidcclient.ConsumeLoginTicket(ticket)
}
func (s *oidcLoginService) loginWithOIDCProfile(profile *oidcLoginProfile, authCfg config.AuthConfig, clientIP, userAgent string) (*response.LoginResponse, error) {
if profile == nil || strings.TrimSpace(profile.Subject) == "" {
return nil, errorsx.BusinessError(2, "OIDC 用户信息不存在")
}
var ret *response.LoginResponse
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
var (
identity = repositories.UserIdentityRepository.GetBy(ctx.Tx, enums.ThirdProviderOIDC, "", profile.Subject)
user *models.User
err error
)
if identity == nil {
user, identity, err = s.createOIDCUser(ctx, profile)
if err != nil {
return err
}
} else {
if identity.Status != enums.StatusOk {
return errorsx.BusinessError(3, "当前 OIDC 绑定已停用")
}
user = repositories.UserRepository.Get(ctx.Tx, identity.UserID)
if user == nil {
return errorsx.BusinessError(4, "OIDC 账号绑定的系统用户不存在")
}
}
if user.Status != enums.StatusOk {
return errorsx.Unauthorized("当前系统账号已被禁用")
}
if err = repositories.UserRepository.Updates(ctx.Tx, user.ID, map[string]any{
"nickname": s.resolveOIDCNickname(user.Nickname, profile),
"avatar": s.resolveOIDCAvatar(user.Avatar, profile),
"last_login_at": time.Now(),
"last_login_ip": clientIP,
"update_user_id": user.ID,
"update_user_name": user.Username,
"updated_at": time.Now(),
}); err != nil {
return err
}
if err = repositories.UserIdentityRepository.Updates(ctx.Tx, identity.ID, map[string]any{
"provider_name": enums.GetThirdProviderLabel(enums.ThirdProviderOIDC),
"raw_profile": profile.RawProfile,
"last_auth_at": time.Now(),
"status": enums.StatusOk,
"update_user_id": user.ID,
"update_user_name": user.Username,
"updated_at": time.Now(),
}); err != nil {
return err
}
ret, err = AuthService.issueTokens(ctx, user, clientIP, userAgent, authCfg)
return err
})
if err != nil {
return nil, err
}
return ret, nil
}
func (s *oidcLoginService) createOIDCUser(ctx *sqls.TxContext, profile *oidcLoginProfile) (*models.User, *models.UserIdentity, error) {
now := time.Now()
email := s.availableEmail(ctx.Tx, profile.Email)
username := s.availableUsername(ctx.Tx, profile)
user := &models.User{
Username: username,
Nickname: s.resolveOIDCNickname("", profile),
Avatar: s.resolveOIDCAvatar("", profile),
Email: email,
Password: "",
PasswordSalt: "",
Status: enums.StatusOk,
AuditFields: models.AuditFields{
CreatedAt: now,
CreateUserID: 0,
CreateUserName: enums.GetThirdProviderLabel(enums.ThirdProviderOIDC),
UpdatedAt: now,
UpdateUserID: 0,
UpdateUserName: enums.GetThirdProviderLabel(enums.ThirdProviderOIDC),
},
}
if err := repositories.UserRepository.Create(ctx.Tx, user); err != nil {
return nil, nil, err
}
identity := &models.UserIdentity{
UserID: user.ID,
Provider: enums.ThirdProviderOIDC,
ProviderUserID: strings.TrimSpace(profile.Subject),
ProviderCorpID: "",
ProviderName: enums.GetThirdProviderLabel(enums.ThirdProviderOIDC),
RawProfile: profile.RawProfile,
Status: enums.StatusOk,
LastAuthAt: &now,
AuditFields: models.AuditFields{
CreatedAt: now,
CreateUserID: user.ID,
CreateUserName: user.Username,
UpdatedAt: now,
UpdateUserID: user.ID,
UpdateUserName: user.Username,
},
}
if err := repositories.UserIdentityRepository.Create(ctx.Tx, identity); err != nil {
return nil, nil, err
}
return user, identity, nil
}
func (s *oidcLoginService) availableEmail(tx *gorm.DB, email string) *string {
email = strings.TrimSpace(strings.ToLower(email))
if email == "" || repositories.UserRepository.GetByEmail(tx, email) != nil {
return nil
}
return &email
}
func (s *oidcLoginService) availableUsername(tx *gorm.DB, profile *oidcLoginProfile) string {
for _, candidate := range []string{
profile.PreferredUsername,
strings.Split(strings.TrimSpace(profile.Email), "@")[0],
} {
username := normalizeOIDCUsername(candidate)
if username != "" && repositories.UserRepository.GetByUsername(tx, username) == nil {
return username
}
}
base := "oidc_" + shortSubjectHash(profile.Subject)
if repositories.UserRepository.GetByUsername(tx, base) == nil {
return base
}
for i := 1; i < 100; i++ {
username := base + "_" + strconv.Itoa(i)
if repositories.UserRepository.GetByUsername(tx, username) == nil {
return username
}
}
return base + "_" + shortSubjectHash(time.Now().String())
}
func (s *oidcLoginService) resolveOIDCNickname(current string, profile *oidcLoginProfile) string {
if profile != nil {
for _, candidate := range []string{profile.Name, profile.PreferredUsername, profile.Email, profile.Subject} {
if candidate = strings.TrimSpace(candidate); candidate != "" {
return candidate
}
}
}
return strings.TrimSpace(current)
}
func (s *oidcLoginService) resolveOIDCAvatar(current string, profile *oidcLoginProfile) string {
if profile != nil {
if picture := strings.TrimSpace(profile.Picture); picture != "" {
return picture
}
}
return strings.TrimSpace(current)
}
func normalizeOIDCUsername(value string) string {
value = strings.TrimSpace(strings.ToLower(value))
var b strings.Builder
for _, r := range value {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-' || r == '.' {
b.WriteRune(r)
}
}
ret := strings.Trim(b.String(), "._-")
if len(ret) > 100 {
ret = ret[:100]
}
return ret
}
func shortSubjectHash(subject string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(subject)))
return hex.EncodeToString(sum[:])[:16]
}
@@ -0,0 +1,96 @@
package services
import (
"strings"
"testing"
"cs-agent/internal/models"
"cs-agent/internal/pkg/config"
"cs-agent/internal/pkg/enums"
)
func TestOIDCLoginAutoCreatesSystemUser(t *testing.T) {
db := setupAuthServiceTestDB(t)
svc := newOIDCLoginService()
ret, err := svc.loginWithOIDCProfile(&oidcLoginProfile{
Subject: "sub-123",
Email: "ada@example.com",
PreferredUsername: "ada",
Name: "Ada Lovelace",
Picture: "https://example.com/ada.png",
RawProfile: `{"sub":"sub-123"}`,
}, config.AuthConfig{TokenTTLHours: 2}, "127.0.0.1", "go-test")
if err != nil {
t.Fatalf("loginWithOIDCProfile() error = %v", err)
}
if ret == nil || !strings.HasPrefix(ret.AccessToken, "ak_") {
t.Fatalf("expected ak_ access token, got %+v", ret)
}
var user models.User
if err := db.Take(&user, "username = ?", "ada").Error; err != nil {
t.Fatalf("expected OIDC user to be created: %v", err)
}
if user.Nickname != "Ada Lovelace" || user.Avatar != "https://example.com/ada.png" {
t.Fatalf("unexpected created user profile: %+v", user)
}
if user.Email == nil || *user.Email != "ada@example.com" {
t.Fatalf("expected email to be stored, got %+v", user.Email)
}
if user.Password != "" {
t.Fatalf("expected OIDC-created user password to be empty, got %q", user.Password)
}
var identity models.UserIdentity
if err := db.Take(&identity, "provider = ? AND provider_user_id = ?", enums.ThirdProviderOIDC, "sub-123").Error; err != nil {
t.Fatalf("expected OIDC identity to be created: %v", err)
}
if identity.UserID != user.ID || identity.ProviderName != "OIDC" || identity.Status != enums.StatusOk {
t.Fatalf("unexpected OIDC identity: %+v", identity)
}
var sessions []models.LoginSession
if err := db.Find(&sessions).Error; err != nil {
t.Fatalf("query login sessions: %v", err)
}
if len(sessions) != 1 || sessions[0].UserID != user.ID || sessions[0].Token != ret.AccessToken {
t.Fatalf("unexpected login sessions: %+v", sessions)
}
}
func TestOIDCLoginReusesExistingIdentity(t *testing.T) {
db := setupAuthServiceTestDB(t)
user := createAuthTestUser(t, db, "existing", "secret")
if err := db.Create(&models.UserIdentity{
UserID: user.ID,
Provider: enums.ThirdProviderOIDC,
ProviderUserID: "sub-123",
ProviderName: "OIDC",
Status: enums.StatusOk,
}).Error; err != nil {
t.Fatalf("seed OIDC identity: %v", err)
}
ret, err := newOIDCLoginService().loginWithOIDCProfile(&oidcLoginProfile{
Subject: "sub-123",
PreferredUsername: "ignored",
Name: "Updated Name",
Picture: "https://example.com/updated.png",
RawProfile: `{"sub":"sub-123"}`,
}, config.AuthConfig{TokenTTLHours: 2}, "127.0.0.1", "go-test")
if err != nil {
t.Fatalf("loginWithOIDCProfile() error = %v", err)
}
if ret == nil || ret.User == nil || ret.User.ID != user.ID {
t.Fatalf("expected existing user login response, got %+v", ret)
}
var count int64
if err := db.Model(&models.User{}).Count(&count).Error; err != nil {
t.Fatalf("count users: %v", err)
}
if count != 1 {
t.Fatalf("expected existing identity to reuse user, got %d users", count)
}
}
+6 -5
View File
@@ -139,6 +139,7 @@ func (s *wxWorkLoginService) createWxWorkUser(ctx *sqls.TxContext, profile *wxwo
username := strings.TrimSpace(profile.UserID)
mobile := strings.TrimSpace(profile.Mobile)
email := strings.TrimSpace(s.firstNonEmpty(profile.Email, profile.BizMail))
now := time.Now()
if err := s.checkWxWorkProfile(ctx.Tx, username, mobile, email); err != nil {
return nil, nil, err
@@ -152,10 +153,10 @@ func (s *wxWorkLoginService) createWxWorkUser(ctx *sqls.TxContext, profile *wxwo
PasswordSalt: "",
Status: enums.StatusOk,
AuditFields: models.AuditFields{
CreatedAt: time.Now(),
CreatedAt: now,
CreateUserID: 0,
CreateUserName: enums.GetThirdProviderLabel(enums.ThirdProviderWxWork),
UpdatedAt: time.Now(),
UpdatedAt: now,
UpdateUserID: 0,
UpdateUserName: enums.GetThirdProviderLabel(enums.ThirdProviderWxWork),
},
@@ -172,12 +173,12 @@ func (s *wxWorkLoginService) createWxWorkUser(ctx *sqls.TxContext, profile *wxwo
ProviderName: enums.GetThirdProviderLabel(enums.ThirdProviderWxWork),
RawProfile: jsons.ToJsonStr(profile),
Status: enums.StatusOk,
LastAuthAt: new(time.Now()),
LastAuthAt: &now,
AuditFields: models.AuditFields{
CreatedAt: time.Now(),
CreatedAt: now,
CreateUserID: user.ID,
CreateUserName: user.Username,
UpdatedAt: time.Now(),
UpdatedAt: now,
UpdateUserID: user.ID,
UpdateUserName: user.Username,
},
@@ -0,0 +1,63 @@
"use client"
import { useRouter, useSearchParams } from "next/navigation"
import { Suspense, useEffect, useRef } from "react"
import { toast } from "sonner"
import { exchangeOIDCTicket } from "@/lib/api/auth"
export default function OIDCLoginCallbackPage() {
return (
<Suspense fallback={<OIDCLoginCallbackFallback />}>
<OIDCLoginCallbackContent />
</Suspense>
)
}
function OIDCLoginCallbackContent() {
const router = useRouter()
const searchParams = useSearchParams()
const ranRef = useRef(false)
useEffect(() => {
if (ranRef.current) {
return
}
ranRef.current = true
const ticket = searchParams.get("ticket")?.trim() ?? ""
const next = searchParams.get("next")
const nextPath = next && next.startsWith("/") ? next : "/dashboard"
if (!ticket) {
toast.error("OIDC 登录票据不存在")
router.replace("/dashboard/login")
return
}
void exchangeOIDCTicket(ticket)
.then(() => {
toast.success("登录成功,正在进入系统")
router.replace(nextPath)
})
.catch((error) => {
toast.error(error instanceof Error ? error.message : "OIDC 登录失败")
router.replace("/dashboard/login")
})
}, [router, searchParams])
return <OIDCLoginCallbackFallback />
}
function OIDCLoginCallbackFallback() {
return (
<div className="flex min-h-svh items-center justify-center bg-[linear-gradient(145deg,#fff7ed_0%,#ffffff_32%,#ecfeff_100%)] px-6">
<div className="w-full max-w-md rounded-[28px] border border-white/70 bg-white/90 p-8 text-center shadow-[0_24px_80px_rgba(15,23,42,0.08)] backdrop-blur">
<h1 className="text-2xl font-semibold tracking-tight">OIDC </h1>
<p className="mt-3 text-sm text-muted-foreground">
</p>
</div>
</div>
)
}
+21
View File
@@ -15,6 +15,7 @@ import {
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { KeyRoundIcon } from "lucide-react"
function detectWxWorkEnvironment() {
if (typeof navigator === "undefined") {
@@ -35,6 +36,7 @@ export function LoginForm({
const [isWxWorkEnv, setIsWxWorkEnv] = useState(false)
const nextPath = searchParams.get("next")
const wxworkError = searchParams.get("wxworkError")
const oidcError = searchParams.get("oidcError")
const redirectPath =
nextPath && nextPath.startsWith("/") ? nextPath : "/dashboard"
@@ -50,6 +52,12 @@ export function LoginForm({
}
}, [wxworkError])
useEffect(() => {
if (oidcError) {
toast.error(oidcError)
}
}, [oidcError])
useEffect(() => {
setIsWxWorkEnv(detectWxWorkEnvironment())
}, [])
@@ -130,6 +138,19 @@ export function LoginForm({
</Button>
</Field>
<Field>
<Button
type="button"
variant="outline"
className="gap-2"
onClick={() => {
window.location.href = `/api/auth/oidc_login?next=${encodeURIComponent(redirectPath)}`
}}
>
<KeyRoundIcon className="size-4 shrink-0" />
OIDC
</Button>
</Field>
</FieldGroup>
</form>
)
+10
View File
@@ -26,6 +26,16 @@ export async function exchangeWxWorkTicket(ticket: string) {
return data
}
export async function exchangeOIDCTicket(ticket: string) {
const data = await request<AuthSession>("/api/auth/oidc_exchange", {
method: "POST",
body: JSON.stringify({ ticket }),
skipAuth: true,
})
writeSession(data)
return data
}
export async function fetchProfile() {
return request<AuthSession>("/api/auth/profile")
}