417 lines
12 KiB
Go
417 lines
12 KiB
Go
package services
|
|
|
|
import (
|
|
"agent-desk/internal/models"
|
|
"agent-desk/internal/oidcclient"
|
|
"agent-desk/internal/pkg/config"
|
|
"agent-desk/internal/pkg/constants"
|
|
"agent-desk/internal/pkg/dto/response"
|
|
"agent-desk/internal/pkg/enums"
|
|
"agent-desk/internal/pkg/errorsx"
|
|
"agent-desk/internal/repositories"
|
|
"context"
|
|
"crypto/sha256"
|
|
"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, verifier, err := oidcclient.ParseState(state)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
profile, err := oidcclient.ExchangeCode(ctx, code, verifier)
|
|
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.BusinessErrorI18n(2, "error.oidc.profileMissing")
|
|
}
|
|
|
|
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.BusinessErrorI18n(3, "error.oidc.bindingDisabled")
|
|
}
|
|
user = repositories.UserRepository.Get(ctx.Tx, identity.UserID)
|
|
if user == nil {
|
|
return errorsx.BusinessErrorI18n(4, "error.oidc.boundUserMissing")
|
|
}
|
|
}
|
|
|
|
if user.Status != enums.StatusOk {
|
|
return errorsx.UnauthorizedI18n("error.e0200")
|
|
}
|
|
|
|
userUpdates := 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(),
|
|
}
|
|
if (user.Email == nil || *user.Email == "") && profile.Email != "" {
|
|
if email := s.availableEmail(ctx.Tx, profile.Email); email != nil {
|
|
userUpdates["email"] = *email
|
|
}
|
|
}
|
|
|
|
if err = repositories.UserRepository.Updates(ctx.Tx, user.ID, userUpdates); err != nil {
|
|
return err
|
|
}
|
|
|
|
s.ensureDefaultOIDCRole(ctx.Tx, user)
|
|
s.syncOIDCUserOrganizations(ctx.Tx, user, profile)
|
|
|
|
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: "",
|
|
UserType: enums.UserTypeEmployee,
|
|
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
|
|
}
|
|
s.ensureDefaultOIDCRole(ctx.Tx, user)
|
|
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]
|
|
}
|
|
|
|
// ensureDefaultOIDCRole gives a first-time OIDC user the lowest staff role.
|
|
// The provider vouches for who the user is, not what they may administer, so
|
|
// a user arriving without any role must never escalate to an administrative
|
|
// one.
|
|
func (s *oidcLoginService) ensureDefaultOIDCRole(tx *gorm.DB, user *models.User) {
|
|
if user == nil || user.ID <= 0 {
|
|
return
|
|
}
|
|
existingRole := repositories.UserRoleRepository.FindOne(tx, sqls.NewCnd().Eq("user_id", user.ID))
|
|
if existingRole != nil {
|
|
return
|
|
}
|
|
defaultRole := repositories.RoleRepository.GetByCode(tx, constants.RoleCodeCsUser)
|
|
if defaultRole == nil {
|
|
return
|
|
}
|
|
now := time.Now()
|
|
_ = repositories.UserRoleRepository.Create(tx, &models.UserRole{
|
|
UserID: user.ID,
|
|
RoleID: defaultRole.ID,
|
|
AuditFields: models.AuditFields{
|
|
CreatedAt: now,
|
|
CreateUserID: user.ID,
|
|
CreateUserName: user.Username,
|
|
UpdatedAt: now,
|
|
UpdateUserID: user.ID,
|
|
UpdateUserName: user.Username,
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *oidcLoginService) syncOIDCUserOrganizations(tx *gorm.DB, user *models.User, profile *oidcLoginProfile) {
|
|
if user == nil || user.ID <= 0 {
|
|
return
|
|
}
|
|
now := time.Now()
|
|
var activeOrgID int64 = user.ActiveOrgID
|
|
|
|
if len(profile.Organizations) > 0 {
|
|
for _, orgClaim := range profile.Organizations {
|
|
orgCode := strings.TrimSpace(orgClaim.ID)
|
|
if orgCode == "" {
|
|
continue
|
|
}
|
|
orgName := strings.TrimSpace(orgClaim.Name)
|
|
if orgName == "" {
|
|
orgName = orgCode
|
|
}
|
|
role := strings.ToUpper(strings.TrimSpace(orgClaim.Role))
|
|
if role == "" {
|
|
role = "MEMBER"
|
|
}
|
|
|
|
org := repositories.OrganizationRepository.GetByCode(tx, orgCode)
|
|
if org == nil {
|
|
org = &models.Organization{
|
|
Code: orgCode,
|
|
Name: orgName,
|
|
Plan: "free",
|
|
Status: enums.StatusOk,
|
|
AuditFields: models.AuditFields{
|
|
CreatedAt: now,
|
|
CreateUserID: user.ID,
|
|
CreateUserName: user.Username,
|
|
UpdatedAt: now,
|
|
UpdateUserID: user.ID,
|
|
UpdateUserName: user.Username,
|
|
},
|
|
}
|
|
if err := repositories.OrganizationRepository.Create(tx, org); err != nil {
|
|
continue
|
|
}
|
|
} else if orgName != "" && org.Name != orgName {
|
|
_ = repositories.OrganizationRepository.UpdateColumn(tx, org.ID, "name", orgName)
|
|
}
|
|
|
|
member := repositories.OrganizationMemberRepository.GetByOrgAndUser(tx, org.ID, user.ID)
|
|
if member == nil {
|
|
member = &models.OrganizationMember{
|
|
OrganizationID: org.ID,
|
|
UserID: user.ID,
|
|
Role: role,
|
|
Status: enums.StatusOk,
|
|
AuditFields: models.AuditFields{
|
|
CreatedAt: now,
|
|
CreateUserID: user.ID,
|
|
CreateUserName: user.Username,
|
|
UpdatedAt: now,
|
|
UpdateUserID: user.ID,
|
|
UpdateUserName: user.Username,
|
|
},
|
|
}
|
|
_ = repositories.OrganizationMemberRepository.Create(tx, member)
|
|
} else if member.Role != role || member.Status != enums.StatusOk {
|
|
_ = repositories.OrganizationMemberRepository.Updates(tx, member.ID, map[string]any{
|
|
"role": role,
|
|
"status": enums.StatusOk,
|
|
"update_user_id": user.ID,
|
|
"update_user_name": user.Username,
|
|
"updated_at": now,
|
|
})
|
|
}
|
|
|
|
if activeOrgID == 0 {
|
|
activeOrgID = org.ID
|
|
}
|
|
}
|
|
} else {
|
|
existingMemberships := repositories.OrganizationMemberRepository.Find(tx, sqls.NewCnd().Eq("user_id", user.ID).Eq("status", enums.StatusOk))
|
|
if len(existingMemberships) == 0 {
|
|
defaultOrgCode := "org_" + shortSubjectHash(profile.Subject)
|
|
defaultOrgName := s.resolveOIDCNickname(user.Nickname, profile) + "'s Workspace"
|
|
org := repositories.OrganizationRepository.GetByCode(tx, defaultOrgCode)
|
|
if org == nil {
|
|
org = &models.Organization{
|
|
Code: defaultOrgCode,
|
|
Name: defaultOrgName,
|
|
Plan: "free",
|
|
Status: enums.StatusOk,
|
|
AuditFields: models.AuditFields{
|
|
CreatedAt: now,
|
|
CreateUserID: user.ID,
|
|
CreateUserName: user.Username,
|
|
UpdatedAt: now,
|
|
UpdateUserID: user.ID,
|
|
UpdateUserName: user.Username,
|
|
},
|
|
}
|
|
_ = repositories.OrganizationRepository.Create(tx, org)
|
|
}
|
|
if org.ID > 0 {
|
|
_ = repositories.OrganizationMemberRepository.Create(tx, &models.OrganizationMember{
|
|
OrganizationID: org.ID,
|
|
UserID: user.ID,
|
|
Role: "OWNER",
|
|
Status: enums.StatusOk,
|
|
AuditFields: models.AuditFields{
|
|
CreatedAt: now,
|
|
CreateUserID: user.ID,
|
|
CreateUserName: user.Username,
|
|
UpdatedAt: now,
|
|
UpdateUserID: user.ID,
|
|
UpdateUserName: user.Username,
|
|
},
|
|
})
|
|
activeOrgID = org.ID
|
|
}
|
|
} else if activeOrgID == 0 {
|
|
activeOrgID = existingMemberships[0].OrganizationID
|
|
}
|
|
}
|
|
|
|
if activeOrgID > 0 && user.ActiveOrgID != activeOrgID {
|
|
user.ActiveOrgID = activeOrgID
|
|
_ = repositories.UserRepository.UpdateColumn(tx, user.ID, "active_org_id", activeOrgID)
|
|
}
|
|
}
|