2026-05-21 06:45:35 +00:00
|
|
|
package auth
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"strings"
|
2026-05-26 16:55:37 +00:00
|
|
|
"time"
|
2026-05-21 06:45:35 +00:00
|
|
|
|
|
|
|
|
errs "gateway/internal/library/errors"
|
|
|
|
|
"gateway/internal/library/errors/code"
|
|
|
|
|
"gateway/internal/library/zitadel"
|
|
|
|
|
domauth "gateway/internal/model/auth/domain/usecase"
|
|
|
|
|
memberdom "gateway/internal/model/member/domain"
|
|
|
|
|
memberenum "gateway/internal/model/member/domain/enum"
|
|
|
|
|
dommember "gateway/internal/model/member/domain/usecase"
|
|
|
|
|
"gateway/internal/svc"
|
|
|
|
|
"gateway/internal/types"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func issueAuthToken(ctx context.Context, sc *svc.ServiceContext, tenantID, uid string) (*types.AuthTokenData, error) {
|
|
|
|
|
if sc.AuthToken == nil {
|
|
|
|
|
return nil, errb.SysNotImplemented("auth token not configured")
|
|
|
|
|
}
|
|
|
|
|
pair, err := sc.AuthToken.IssuePair(ctx, &domauth.IssuePairRequest{
|
|
|
|
|
TenantID: tenantID,
|
|
|
|
|
UID: uid,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return &types.AuthTokenData{
|
|
|
|
|
AccessToken: pair.AccessToken,
|
|
|
|
|
RefreshToken: pair.RefreshToken,
|
|
|
|
|
ExpiresIn: pair.ExpiresIn,
|
|
|
|
|
UID: uid,
|
|
|
|
|
TokenType: pair.TokenType,
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func tokenDataFromRefresh(ctx context.Context, sc *svc.ServiceContext, refreshToken string) (*types.AuthTokenData, error) {
|
|
|
|
|
if sc.AuthToken == nil {
|
|
|
|
|
return nil, errb.SysNotImplemented("auth token not configured")
|
|
|
|
|
}
|
|
|
|
|
pair, err := sc.AuthToken.Refresh(ctx, refreshToken)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
claims, err := sc.AuthToken.ParseAccessToken(ctx, pair.AccessToken)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return &types.AuthTokenData{
|
|
|
|
|
AccessToken: pair.AccessToken,
|
|
|
|
|
RefreshToken: pair.RefreshToken,
|
|
|
|
|
ExpiresIn: pair.ExpiresIn,
|
|
|
|
|
UID: claims.UID,
|
|
|
|
|
TokenType: pair.TokenType,
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func zitadelIdentityFromToken(ctx context.Context, client *zitadel.Client, tok *zitadel.TokenResult) (*zitadel.IDTokenClaims, error) {
|
|
|
|
|
if tok == nil {
|
|
|
|
|
return nil, errb.SvcThirdParty("empty token result")
|
|
|
|
|
}
|
2026-05-26 09:32:32 +00:00
|
|
|
if tok.Subject != "" {
|
|
|
|
|
return &zitadel.IDTokenClaims{
|
|
|
|
|
Sub: tok.Subject,
|
|
|
|
|
Email: tok.Email,
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
2026-05-21 06:45:35 +00:00
|
|
|
if tok.IDToken != "" {
|
|
|
|
|
claims, err := zitadel.ParseIDTokenClaims(tok.IDToken)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, errb.SvcThirdParty("parse id_token failed").WithCause(err)
|
|
|
|
|
}
|
|
|
|
|
return claims, nil
|
|
|
|
|
}
|
|
|
|
|
claims, err := client.FetchUserInfo(ctx, tok.AccessToken)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, wrapZitadelErr(err)
|
|
|
|
|
}
|
|
|
|
|
return claims, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func memberForLogin(ctx context.Context, sc *svc.ServiceContext, tenantID, zitadelSub string) (*dommember.MemberDTO, error) {
|
|
|
|
|
if sc.MemberProfile == nil {
|
|
|
|
|
return nil, errb.SysNotImplemented("member profile not configured")
|
|
|
|
|
}
|
|
|
|
|
dto, err := sc.MemberProfile.GetByZitadelUserID(ctx, tenantID, zitadelSub)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if e := errs.FromError(err); e != nil && e.Category() == code.ResNotFound {
|
|
|
|
|
return nil, errb.AuthUnauthorized("invalid credentials").WithCause(memberdom.ErrNotFound)
|
|
|
|
|
}
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if err := ensureLoginEligible(dto.Status); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return dto, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func ensureLoginEligible(status memberenum.MemberStatus) error {
|
|
|
|
|
switch status {
|
|
|
|
|
case memberenum.MemberStatusActive:
|
|
|
|
|
return nil
|
|
|
|
|
case memberenum.MemberStatusUnverified:
|
|
|
|
|
return errb.AuthForbidden("account is not verified")
|
|
|
|
|
case memberenum.MemberStatusSuspended:
|
|
|
|
|
return errb.AuthForbidden("account is suspended")
|
|
|
|
|
case memberenum.MemberStatusDeleted:
|
|
|
|
|
return errb.AuthUnauthorized("invalid credentials")
|
|
|
|
|
default:
|
|
|
|
|
return errb.AuthForbidden("account is not allowed to login")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func normalizeLoginEmail(email string) string {
|
|
|
|
|
return strings.TrimSpace(strings.ToLower(email))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func requireLoginDeps(sc *svc.ServiceContext) error {
|
|
|
|
|
if sc.Zitadel == nil {
|
|
|
|
|
return errb.SysNotImplemented("zitadel not configured")
|
|
|
|
|
}
|
|
|
|
|
if sc.MemberProfile == nil {
|
|
|
|
|
return errb.SysNotImplemented("member profile not configured")
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func isMemberNotFound(err error) bool {
|
|
|
|
|
e := errs.FromError(err)
|
|
|
|
|
return e != nil && e.Category() == code.ResNotFound
|
|
|
|
|
}
|
2026-05-26 16:55:37 +00:00
|
|
|
|
|
|
|
|
func loginDataFromTokens(tokens *types.AuthTokenData) *types.LoginData {
|
|
|
|
|
if tokens == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return &types.LoginData{
|
|
|
|
|
AccessToken: tokens.AccessToken,
|
|
|
|
|
RefreshToken: tokens.RefreshToken,
|
|
|
|
|
ExpiresIn: tokens.ExpiresIn,
|
|
|
|
|
UID: tokens.UID,
|
|
|
|
|
TokenType: tokens.TokenType,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func beginLoginMFA(ctx context.Context, sc *svc.ServiceContext, tenantID, tenantSlug, uid string) (*types.LoginData, error) {
|
|
|
|
|
if sc.AuthLoginMFAChallenge == nil {
|
|
|
|
|
return nil, errb.SysNotImplemented("login mfa challenge not configured")
|
|
|
|
|
}
|
|
|
|
|
if sc.MemberTOTP == nil {
|
|
|
|
|
return nil, errb.SysNotImplemented("member TOTP not configured")
|
|
|
|
|
}
|
|
|
|
|
ttl := time.Duration(sc.Config.Auth.Defaults().RegistrationSessionTTLSeconds) * time.Second
|
|
|
|
|
challenge, err := sc.AuthLoginMFAChallenge.Create(ctx, &domauth.CreateLoginMFAChallengeRequest{
|
|
|
|
|
TenantID: tenantID,
|
|
|
|
|
TenantSlug: tenantSlug,
|
|
|
|
|
UID: uid,
|
|
|
|
|
TTL: ttl,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return &types.LoginData{
|
|
|
|
|
MFARequired: true,
|
|
|
|
|
MFAChallengeID: challenge.ChallengeID,
|
|
|
|
|
MFAExpiresIn: challenge.ExpiresIn,
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func confirmLoginMFA(ctx context.Context, sc *svc.ServiceContext, tenantSlug, challengeID, totpCode string) (*types.AuthTokenData, error) {
|
|
|
|
|
if sc.AuthLoginMFAChallenge == nil {
|
|
|
|
|
return nil, errb.SysNotImplemented("login mfa challenge not configured")
|
|
|
|
|
}
|
|
|
|
|
if sc.MemberTOTP == nil {
|
|
|
|
|
return nil, errb.SysNotImplemented("member TOTP not configured")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tenant, err := resolveTenant(ctx, sc, tenantSlug)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
challenge, err := sc.AuthLoginMFAChallenge.Get(ctx, challengeID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if challenge.TenantID != tenant.TenantID {
|
|
|
|
|
return nil, errb.AuthForbidden("login mfa challenge tenant mismatch")
|
|
|
|
|
}
|
|
|
|
|
if !strings.EqualFold(strings.TrimSpace(challenge.TenantSlug), strings.TrimSpace(tenantSlug)) {
|
|
|
|
|
return nil, errb.AuthForbidden("login mfa challenge tenant mismatch")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
member, err := sc.MemberProfile.GetByUID(ctx, &dommember.GetMemberRequest{
|
|
|
|
|
TenantID: challenge.TenantID,
|
|
|
|
|
UID: challenge.UID,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if err := ensureLoginEligible(member.Status); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if !member.TOTPEnrolled {
|
|
|
|
|
return nil, errb.ResInvalidState("totp not enrolled").WithCause(memberdom.ErrTOTPNotEnrolled)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := sc.MemberTOTP.VerifyCode(ctx, challenge.TenantID, challenge.UID, strings.TrimSpace(totpCode)); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := sc.AuthLoginMFAChallenge.Delete(ctx, challengeID); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return issueAuthToken(ctx, sc, challenge.TenantID, challenge.UID)
|
|
|
|
|
}
|