⚠️ 应用类型约束:当前实现仅支持「钉钉登录-企业内部应用」(DingTalk 开放平台
internal_app 类型)。第三方个人应用、第三方企业应用类型暂不支持——OAuth 流程
相同但 corp 校验、跨企业行为不同。backend 通过 DingTalkAppKind 校验对非
internal_app 类型 fail-closed(硬约束)。
钉钉 OAuth 登录主链
- 4 步 OAuth 链:ExchangeCodeForUserToken / GetUnionIdByUserToken /
GetUserIdByUnionId / GetStaffInfoByUserId;app token 缓存
- pending session 机制持久化 OAuth 中间态;cookie-only token 持久化
- 三种分流:bind_login_required / email_completion / choose_account_action
- corp_restriction_policy 支持 none + internal_only;stale "whitelist" 在
加载层与写入层均静默 coerce 为 none + slog.Warn
- bypass_registration 开关:企业内部模式豁免全局 REGISTRATION_DISABLED
- isReservedEmail / signup_source / canUnbindProvider / OAuth pending flow
等横切点支持 dingtalk provider
- migration 136:4 表 CHECK 约束加入 'dingtalk' provider 值
internal_only 模式同步企业邮箱/姓名/部门到用户属性
- SyncCorpEmail / SyncDisplayName / SyncDept 三个独立开关 + 对应
SyncXxxAttrKey 目标属性 key(默认 dingtalk_email / dingtalk_name /
dingtalk_department);非 internal_only policy 在写入层与加载层均
coerce 为 false,admin handler 与 setting_service 双层兜底
- 同步语义:首次注册写 users.username(昵称优先 → 企业姓名 fallback),
之后每次登录刷新 3 个属性;空值也写入以覆盖旧值
- 邮箱三级 fallback:org_email > email > extension["企业邮箱"]
(钉钉自定义字段 JSON)
- 部门路径递归向上拼接,跳过 dept_id=1 选首个真实子部门,剥离根组织名
- GetUnionIdByUserToken 同时返回 OIDC /contact/users/me 的 nick 字段;
新增 GetDeptInfo 调用 OAPI /topapi/v2/department/get
- AuthHandler 注入 UserAttributeService;OAuth pending flow 在
createPendingOAuthAccount / bindPendingOAuthLogin 分别派发到
AfterRegistration(syncUsername=true)/ AfterLogin
- migration 137 seed dingtalk_email/name/department 三个用户属性定义
附带修复(同集成路径暴露的两个 OAuth 注册回归)
- LoginOrRegisterOAuthWithTokenPair 新建用户分支用 inferLegacySignupSource
覆写 caller 显式传入的 signupSource,导致 dingtalk/linuxdo/oidc/wechat
渠道授权按 email 渠道读取;改为只在 caller 未显式传入时回退邮箱推断
- mergeProviderDefaultGrantSettings 把 parse fallback 默认值
(Concurrency=5 / Balance=0) 当作"未配置"哨兵,admin 显式设 5 时被误判
退回全局默认(复现:全局默认 1 + 渠道默认并发 5 + grant_on_signup → 新
用户实际 concurrency=1);去掉哨兵,admin 任何 >=0 值都覆盖 globalDefaults
前端
- DingTalk Login / Callback / EmailCompletion / ChoiceAccount / Error
视图;router + auth API client
- admin SettingsView:corp policy radio(none / internal_only)+ bypass
注册开关 + i18n;internal_only 下展示三同步开关 + 目标 attr key 下拉
(拉取 user attribute definitions),展示 fieldEmail /
qyapi_get_department_list 钉钉权限申请提示
- Profile:S1 主动绑定 / S5 解绑钉钉按钮 + 合成邮箱防自锁
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
476 lines
14 KiB
Go
476 lines
14 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/mail"
|
|
"strings"
|
|
"time"
|
|
|
|
dbent "github.com/Wei-Shaw/sub2api/ent"
|
|
"github.com/Wei-Shaw/sub2api/ent/redeemcode"
|
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
|
)
|
|
|
|
func normalizeOAuthSignupSource(signupSource string) string {
|
|
signupSource = strings.TrimSpace(strings.ToLower(signupSource))
|
|
switch signupSource {
|
|
case "", "email":
|
|
return "email"
|
|
case "linuxdo", "wechat", "oidc", "github", "google", "dingtalk":
|
|
return signupSource
|
|
default:
|
|
return "email"
|
|
}
|
|
}
|
|
|
|
// SendPendingOAuthVerifyCode sends a local verification code for pending OAuth
|
|
// account-creation flows without relying on the public registration gate.
|
|
func (s *AuthService) SendPendingOAuthVerifyCode(ctx context.Context, email string) (*SendVerifyCodeResult, error) {
|
|
email = strings.TrimSpace(strings.ToLower(email))
|
|
if email == "" {
|
|
return nil, ErrEmailVerifyRequired
|
|
}
|
|
if _, err := mail.ParseAddress(email); err != nil {
|
|
return nil, ErrEmailVerifyRequired
|
|
}
|
|
if isReservedEmail(email) {
|
|
return nil, ErrEmailReserved
|
|
}
|
|
if s == nil || s.emailService == nil {
|
|
return nil, ErrServiceUnavailable
|
|
}
|
|
|
|
siteName := "Sub2API"
|
|
if s.settingService != nil {
|
|
siteName = s.settingService.GetSiteName(ctx)
|
|
}
|
|
if err := s.emailService.SendVerifyCode(ctx, email, siteName); err != nil {
|
|
return nil, err
|
|
}
|
|
return &SendVerifyCodeResult{
|
|
Countdown: int(verifyCodeCooldown / time.Second),
|
|
}, nil
|
|
}
|
|
|
|
func (s *AuthService) validateOAuthRegistrationInvitation(ctx context.Context, invitationCode string) (*RedeemCode, error) {
|
|
if s == nil || s.settingService == nil || !s.settingService.IsInvitationCodeEnabled(ctx) {
|
|
return nil, nil
|
|
}
|
|
if s.redeemRepo == nil && s.oauthEmailFlowClient(ctx) == nil {
|
|
return nil, ErrServiceUnavailable
|
|
}
|
|
|
|
invitationCode = strings.TrimSpace(invitationCode)
|
|
if invitationCode == "" {
|
|
return nil, ErrInvitationCodeRequired
|
|
}
|
|
|
|
redeemCode, err := s.loadOAuthRegistrationInvitation(ctx, invitationCode)
|
|
if err != nil {
|
|
return nil, ErrInvitationCodeInvalid
|
|
}
|
|
if redeemCode.Type != RedeemTypeInvitation || redeemCode.Status != StatusUnused {
|
|
return nil, ErrInvitationCodeInvalid
|
|
}
|
|
return redeemCode, nil
|
|
}
|
|
|
|
// VerifyOAuthEmailCode verifies the locally entered email verification code for
|
|
// third-party signup and binding flows. This is intentionally independent from
|
|
// the global registration email verification toggle.
|
|
func (s *AuthService) VerifyOAuthEmailCode(ctx context.Context, email, verifyCode string) error {
|
|
email = strings.TrimSpace(strings.ToLower(email))
|
|
verifyCode = strings.TrimSpace(verifyCode)
|
|
|
|
if email == "" {
|
|
return ErrEmailVerifyRequired
|
|
}
|
|
if verifyCode == "" {
|
|
return ErrEmailVerifyRequired
|
|
}
|
|
if s == nil || s.emailService == nil {
|
|
return ErrServiceUnavailable
|
|
}
|
|
return s.emailService.VerifyCode(ctx, email, verifyCode)
|
|
}
|
|
|
|
// RegisterOAuthEmailAccount creates a local account from a third-party first
|
|
// login after the user has verified a local email address.
|
|
func (s *AuthService) RegisterOAuthEmailAccount(
|
|
ctx context.Context,
|
|
email string,
|
|
password string,
|
|
verifyCode string,
|
|
invitationCode string,
|
|
signupSource string,
|
|
) (*TokenPair, *User, error) {
|
|
if s == nil {
|
|
return nil, nil, ErrServiceUnavailable
|
|
}
|
|
if s.settingService == nil || (!s.settingService.IsRegistrationEnabled(ctx) && !s.canBypassRegistrationDisabledForOAuth(ctx, signupSource)) {
|
|
return nil, nil, ErrRegDisabled
|
|
}
|
|
|
|
email = strings.TrimSpace(strings.ToLower(email))
|
|
if isReservedEmail(email) {
|
|
return nil, nil, ErrEmailReserved
|
|
}
|
|
if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil {
|
|
slog.Error("oauth email register: policy rejected", "email", email, "error", err.Error())
|
|
return nil, nil, err
|
|
}
|
|
if err := s.VerifyOAuthEmailCode(ctx, email, verifyCode); err != nil {
|
|
slog.Error("oauth email register: verify code failed", "email", email, "error", err.Error())
|
|
return nil, nil, err
|
|
}
|
|
|
|
if _, err := s.validateOAuthRegistrationInvitation(ctx, invitationCode); err != nil {
|
|
slog.Error("oauth email register: invitation failed", "email", email, "error", err.Error())
|
|
return nil, nil, err
|
|
}
|
|
|
|
existsEmail, err := s.userRepo.ExistsByEmail(ctx, email)
|
|
if err != nil {
|
|
slog.Error("oauth email register: ExistsByEmail failed", "email", email, "error", err.Error())
|
|
return nil, nil, ErrServiceUnavailable
|
|
}
|
|
if existsEmail {
|
|
return nil, nil, ErrEmailExists
|
|
}
|
|
|
|
hashedPassword, err := s.HashPassword(password)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("hash password: %w", err)
|
|
}
|
|
|
|
signupSource = normalizeOAuthSignupSource(signupSource)
|
|
grantPlan := s.resolveSignupGrantPlan(ctx, signupSource)
|
|
|
|
user := &User{
|
|
Email: email,
|
|
PasswordHash: hashedPassword,
|
|
Role: RoleUser,
|
|
Balance: grantPlan.Balance,
|
|
Concurrency: grantPlan.Concurrency,
|
|
Status: StatusActive,
|
|
SignupSource: signupSource,
|
|
}
|
|
|
|
if err := s.userRepo.Create(ctx, user); err != nil {
|
|
if errors.Is(err, ErrEmailExists) {
|
|
return nil, nil, ErrEmailExists
|
|
}
|
|
slog.Error("oauth email register: userRepo.Create failed", "email", email, "signup_source", signupSource, "error", err.Error())
|
|
return nil, nil, ErrServiceUnavailable
|
|
}
|
|
|
|
tokenPair, err := s.GenerateTokenPair(ctx, user, "")
|
|
if err != nil {
|
|
_ = s.RollbackOAuthEmailAccountCreation(ctx, user.ID, "")
|
|
return nil, nil, fmt.Errorf("generate token pair: %w", err)
|
|
}
|
|
return tokenPair, user, nil
|
|
}
|
|
|
|
// RegisterVerifiedOAuthEmailAccount creates a local account from an OAuth
|
|
// provider that has already returned a verified email address.
|
|
func (s *AuthService) RegisterVerifiedOAuthEmailAccount(
|
|
ctx context.Context,
|
|
email string,
|
|
password string,
|
|
invitationCode string,
|
|
signupSource string,
|
|
) (*TokenPair, *User, error) {
|
|
if s == nil {
|
|
return nil, nil, ErrServiceUnavailable
|
|
}
|
|
if s.settingService == nil || (!s.settingService.IsRegistrationEnabled(ctx) && !s.canBypassRegistrationDisabledForOAuth(ctx, signupSource)) {
|
|
return nil, nil, ErrRegDisabled
|
|
}
|
|
|
|
email = strings.TrimSpace(strings.ToLower(email))
|
|
if email == "" || len(email) > 255 {
|
|
return nil, nil, ErrEmailVerifyRequired
|
|
}
|
|
if _, err := mail.ParseAddress(email); err != nil {
|
|
return nil, nil, ErrEmailVerifyRequired
|
|
}
|
|
if isReservedEmail(email) {
|
|
return nil, nil, ErrEmailReserved
|
|
}
|
|
if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if strings.TrimSpace(password) == "" {
|
|
return nil, nil, infraerrors.BadRequest("PASSWORD_REQUIRED", "password is required")
|
|
}
|
|
if _, err := s.validateOAuthRegistrationInvitation(ctx, invitationCode); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
existsEmail, err := s.userRepo.ExistsByEmail(ctx, email)
|
|
if err != nil {
|
|
return nil, nil, ErrServiceUnavailable
|
|
}
|
|
if existsEmail {
|
|
return nil, nil, ErrEmailExists
|
|
}
|
|
|
|
hashedPassword, err := s.HashPassword(password)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("hash password: %w", err)
|
|
}
|
|
|
|
signupSource = normalizeOAuthSignupSource(signupSource)
|
|
grantPlan := s.resolveSignupGrantPlan(ctx, signupSource)
|
|
var defaultRPMLimit int
|
|
if s.settingService != nil {
|
|
defaultRPMLimit = s.settingService.GetDefaultUserRPMLimit(ctx)
|
|
}
|
|
user := &User{
|
|
Email: email,
|
|
PasswordHash: hashedPassword,
|
|
Role: RoleUser,
|
|
Balance: grantPlan.Balance,
|
|
Concurrency: grantPlan.Concurrency,
|
|
RPMLimit: defaultRPMLimit,
|
|
Status: StatusActive,
|
|
SignupSource: signupSource,
|
|
}
|
|
|
|
if err := s.userRepo.Create(ctx, user); err != nil {
|
|
if errors.Is(err, ErrEmailExists) {
|
|
return nil, nil, ErrEmailExists
|
|
}
|
|
return nil, nil, ErrServiceUnavailable
|
|
}
|
|
|
|
tokenPair, err := s.GenerateTokenPair(ctx, user, "")
|
|
if err != nil {
|
|
_ = s.RollbackOAuthEmailAccountCreation(ctx, user.ID, "")
|
|
return nil, nil, fmt.Errorf("generate token pair: %w", err)
|
|
}
|
|
return tokenPair, user, nil
|
|
}
|
|
|
|
// FinalizeOAuthEmailAccount applies invitation usage and normal signup bootstrap
|
|
// only after the pending OAuth flow has fully reached its last reversible step.
|
|
func (s *AuthService) FinalizeOAuthEmailAccount(
|
|
ctx context.Context,
|
|
user *User,
|
|
invitationCode string,
|
|
signupSource string,
|
|
affiliateCode string,
|
|
) error {
|
|
if s == nil || user == nil || user.ID <= 0 {
|
|
return ErrServiceUnavailable
|
|
}
|
|
|
|
signupSource = normalizeOAuthSignupSource(signupSource)
|
|
invitationRedeemCode, err := s.validateOAuthRegistrationInvitation(ctx, invitationCode)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if invitationRedeemCode != nil {
|
|
if err := s.useOAuthRegistrationInvitation(ctx, invitationRedeemCode.ID, user.ID); err != nil {
|
|
return ErrInvitationCodeInvalid
|
|
}
|
|
}
|
|
|
|
s.updateOAuthSignupSource(ctx, user.ID, signupSource)
|
|
grantPlan := s.resolveSignupGrantPlan(ctx, signupSource)
|
|
s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults")
|
|
s.bindOAuthAffiliate(ctx, user.ID, affiliateCode)
|
|
return nil
|
|
}
|
|
|
|
// RollbackOAuthEmailAccountCreation removes a partially-created local account
|
|
// and restores any invitation code already consumed by that account.
|
|
func (s *AuthService) RollbackOAuthEmailAccountCreation(ctx context.Context, userID int64, invitationCode string) error {
|
|
if s == nil || s.userRepo == nil || userID <= 0 {
|
|
return ErrServiceUnavailable
|
|
}
|
|
if err := s.restoreOAuthRegistrationInvitation(ctx, invitationCode, userID); err != nil {
|
|
return err
|
|
}
|
|
if err := s.userRepo.Delete(ctx, userID); err != nil {
|
|
return fmt.Errorf("delete created oauth user: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *AuthService) restoreOAuthRegistrationInvitation(ctx context.Context, invitationCode string, userID int64) error {
|
|
if s == nil || s.settingService == nil || !s.settingService.IsInvitationCodeEnabled(ctx) {
|
|
return nil
|
|
}
|
|
if s.redeemRepo == nil && s.oauthEmailFlowClient(ctx) == nil {
|
|
return ErrServiceUnavailable
|
|
}
|
|
|
|
invitationCode = strings.TrimSpace(invitationCode)
|
|
if invitationCode == "" || userID <= 0 {
|
|
return nil
|
|
}
|
|
|
|
redeemCode, err := s.loadOAuthRegistrationInvitation(ctx, invitationCode)
|
|
if err != nil {
|
|
if errors.Is(err, ErrRedeemCodeNotFound) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("load invitation code: %w", err)
|
|
}
|
|
if redeemCode.Type != RedeemTypeInvitation || redeemCode.Status != StatusUsed || redeemCode.UsedBy == nil || *redeemCode.UsedBy != userID {
|
|
return nil
|
|
}
|
|
|
|
redeemCode.Status = StatusUnused
|
|
redeemCode.UsedBy = nil
|
|
redeemCode.UsedAt = nil
|
|
if err := s.updateOAuthRegistrationInvitation(ctx, redeemCode); err != nil {
|
|
return fmt.Errorf("restore invitation code: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *AuthService) oauthEmailFlowClient(ctx context.Context) *dbent.Client {
|
|
if s == nil || s.entClient == nil {
|
|
return nil
|
|
}
|
|
if tx := dbent.TxFromContext(ctx); tx != nil {
|
|
return tx.Client()
|
|
}
|
|
return s.entClient
|
|
}
|
|
|
|
func (s *AuthService) loadOAuthRegistrationInvitation(ctx context.Context, invitationCode string) (*RedeemCode, error) {
|
|
if client := s.oauthEmailFlowClient(ctx); client != nil {
|
|
entity, err := client.RedeemCode.Query().Where(redeemcode.CodeEQ(invitationCode)).Only(ctx)
|
|
if err != nil {
|
|
if dbent.IsNotFound(err) {
|
|
return nil, ErrRedeemCodeNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return &RedeemCode{
|
|
ID: entity.ID,
|
|
Code: entity.Code,
|
|
Type: entity.Type,
|
|
Value: entity.Value,
|
|
Status: entity.Status,
|
|
UsedBy: entity.UsedBy,
|
|
UsedAt: entity.UsedAt,
|
|
Notes: oauthEmailFlowStringValue(entity.Notes),
|
|
CreatedAt: entity.CreatedAt,
|
|
GroupID: entity.GroupID,
|
|
ValidityDays: entity.ValidityDays,
|
|
}, nil
|
|
}
|
|
return s.redeemRepo.GetByCode(ctx, invitationCode)
|
|
}
|
|
|
|
func (s *AuthService) useOAuthRegistrationInvitation(ctx context.Context, invitationID, userID int64) error {
|
|
if client := s.oauthEmailFlowClient(ctx); client != nil {
|
|
affected, err := client.RedeemCode.Update().
|
|
Where(redeemcode.IDEQ(invitationID), redeemcode.StatusEQ(StatusUnused)).
|
|
SetStatus(StatusUsed).
|
|
SetUsedBy(userID).
|
|
SetUsedAt(time.Now().UTC()).
|
|
Save(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if affected == 0 {
|
|
return ErrRedeemCodeUsed
|
|
}
|
|
return nil
|
|
}
|
|
return s.redeemRepo.Use(ctx, invitationID, userID)
|
|
}
|
|
|
|
func (s *AuthService) updateOAuthRegistrationInvitation(ctx context.Context, code *RedeemCode) error {
|
|
if code == nil {
|
|
return nil
|
|
}
|
|
if client := s.oauthEmailFlowClient(ctx); client != nil {
|
|
update := client.RedeemCode.UpdateOneID(code.ID).
|
|
SetCode(code.Code).
|
|
SetType(code.Type).
|
|
SetValue(code.Value).
|
|
SetStatus(code.Status).
|
|
SetNotes(code.Notes).
|
|
SetValidityDays(code.ValidityDays)
|
|
if code.UsedBy != nil {
|
|
update = update.SetUsedBy(*code.UsedBy)
|
|
} else {
|
|
update = update.ClearUsedBy()
|
|
}
|
|
if code.UsedAt != nil {
|
|
update = update.SetUsedAt(*code.UsedAt)
|
|
} else {
|
|
update = update.ClearUsedAt()
|
|
}
|
|
if code.GroupID != nil {
|
|
update = update.SetGroupID(*code.GroupID)
|
|
} else {
|
|
update = update.ClearGroupID()
|
|
}
|
|
_, err := update.Save(ctx)
|
|
return err
|
|
}
|
|
return s.redeemRepo.Update(ctx, code)
|
|
}
|
|
|
|
func (s *AuthService) updateOAuthSignupSource(ctx context.Context, userID int64, signupSource string) {
|
|
client := s.oauthEmailFlowClient(ctx)
|
|
if client == nil || userID <= 0 || strings.TrimSpace(signupSource) == "" {
|
|
return
|
|
}
|
|
_ = client.User.UpdateOneID(userID).SetSignupSource(signupSource).Exec(ctx)
|
|
}
|
|
|
|
func oauthEmailFlowStringValue(value *string) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
return *value
|
|
}
|
|
|
|
// ValidatePasswordCredentials checks the local password without completing the
|
|
// login flow. This is used by pending third-party account adoption flows before
|
|
// the external identity has been bound.
|
|
func (s *AuthService) ValidatePasswordCredentials(ctx context.Context, email, password string) (*User, error) {
|
|
if s == nil {
|
|
return nil, ErrServiceUnavailable
|
|
}
|
|
|
|
user, err := s.userRepo.GetByEmail(ctx, strings.TrimSpace(strings.ToLower(email)))
|
|
if err != nil {
|
|
if errors.Is(err, ErrUserNotFound) {
|
|
return nil, ErrInvalidCredentials
|
|
}
|
|
return nil, ErrServiceUnavailable
|
|
}
|
|
if !user.IsActive() {
|
|
return nil, ErrUserNotActive
|
|
}
|
|
if !s.CheckPassword(password, user.PasswordHash) {
|
|
return nil, ErrInvalidCredentials
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
// RecordSuccessfulLogin updates last-login activity after a non-standard login
|
|
// flow finishes with a real session.
|
|
func (s *AuthService) RecordSuccessfulLogin(ctx context.Context, userID int64) {
|
|
if s != nil && s.userRepo != nil && userID > 0 {
|
|
user, err := s.userRepo.GetByID(ctx, userID)
|
|
if err == nil && user != nil && !isReservedEmail(user.Email) {
|
|
s.backfillEmailIdentityOnSuccessfulLogin(ctx, user)
|
|
}
|
|
}
|
|
s.touchUserLogin(ctx, userID)
|
|
}
|