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
+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,
},