yao/openapi/user/login.go
Max 007cf0adb9 Update token scope references in user entry and login processes
- Changed token scope from `ScopeInviteVerification` to `ScopeEntryVerification` in both `GinVerifyInvite` and `LoginByUserID` functions to align with updated authorization requirements.
- Updated error messages to reflect the new expected scope for improved clarity in responses.
2025-11-04 16:52:03 +08:00

677 lines
21 KiB
Go

package user
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/yaoapp/gou/session"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/openapi/oauth"
"github.com/yaoapp/yao/openapi/oauth/providers/user"
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
"github.com/yaoapp/yao/openapi/response"
"github.com/yaoapp/yao/openapi/utils"
"github.com/yaoapp/yao/utils/captcha"
)
// getCaptcha is the handler for get captcha image for entry (login/register)
func getCaptcha(c *gin.Context) {
var option captcha.Option = captcha.NewOption()
err := c.ShouldBindQuery(&option)
if err != nil {
response.RespondWithError(c, http.StatusBadRequest, &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: err.Error(),
})
return
}
// Set the type to image
option.Type = "image"
id, content := captcha.Generate(option)
// Return in the format expected by the frontend
response.RespondWithSuccess(c, http.StatusOK, gin.H{
"captcha_id": id,
"captcha_image": content,
"expires_in": 300, // 5 minutes
})
}
// LoginThirdParty is the handler for third party login
func LoginThirdParty(providerID string, userinfo *oauthtypes.OIDCUserInfo, loginCtx *LoginContext, locale string) (*LoginResponse, error) {
// Get provider
provider, err := GetProvider(providerID)
if err != nil {
return nil, err
}
// Get entry configuration for role and type
entryConfig := GetEntryConfig(locale)
if entryConfig == nil {
// If no entry config found, try to get default entry config
log.Warn("Entry configuration not found for locale '%s', trying default locale 'en'", locale)
entryConfig = GetEntryConfig("en")
if entryConfig == nil {
return nil, fmt.Errorf("entry configuration not found. Please create entry config files in openapi/user/entry/")
}
}
// Check if user exists
userProvider, err := oauth.OAuth.GetUserProvider()
if err != nil {
return nil, err
}
// Auto register user if not exists
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var userID string
// Auto register user if not exists
if provider.Register != nil && provider.Register.Auto {
userID, err = userProvider.GetOAuthUserID(ctx, providerID, userinfo.Sub)
if err != nil && err.Error() == user.ErrOAuthAccountNotFound {
// Determine initial status based on invite requirement
status := "active"
if entryConfig.InviteRequired {
status = "pending_invite" // Waiting for invite code verification
}
userData := map[string]interface{}{
"name": userinfo.Name,
"given_name": userinfo.GivenName,
"family_name": userinfo.FamilyName,
"picture": userinfo.Picture,
"role_id": entryConfig.Role,
"type_id": entryConfig.Type,
"status": status,
}
// Auto register user
userID, err = userProvider.CreateUser(ctx, userData)
if err != nil {
return nil, err
}
// Create OAuth account
userData = userinfo.Map()
userData["provider"] = providerID
_, err = userProvider.CreateOAuthAccount(ctx, userID, userData)
if err != nil {
return nil, err
}
}
}
// Get User ID from OAuth account
userID, err = userProvider.GetOAuthUserID(ctx, providerID, userinfo.Sub)
if err != nil {
return nil, err
}
return LoginByUserID(userID, loginCtx)
}
// LoginByUserID is the handler for login by user ID
func LoginByUserID(userid string, loginCtx *LoginContext) (*LoginResponse, error) {
// Get User
userProvider, err := oauth.OAuth.GetUserProvider()
if err != nil {
return nil, err
}
// Get User
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
user, err := userProvider.GetUserWithScopes(ctx, userid)
if err != nil {
return nil, err
}
yaoClientConfig := GetYaoClientConfig()
var scopes []string = yaoClientConfig.Scopes
if v, ok := user["scopes"].([]string); ok {
scopes = v
}
subject, err := oauth.OAuth.Subject(yaoClientConfig.ClientID, userid)
if err != nil {
log.Warn("Failed to store user fingerprint: %s", err.Error())
}
// Check user status first - handle all non-active statuses
status, _ := user["status"].(string)
switch status {
case "pending":
return nil, fmt.Errorf("account is pending activation. Please contact administrator")
case "email_unverified":
return nil, fmt.Errorf("email is not verified. Please verify your email address")
case "disabled":
return nil, fmt.Errorf("account is disabled. Please contact administrator")
case "suspended":
return nil, fmt.Errorf("account is suspended. Please contact administrator")
case "locked":
return nil, fmt.Errorf("account is locked. Please contact administrator")
case "archived":
return nil, fmt.Errorf("account is archived. Please contact administrator")
case "password_expired":
return nil, fmt.Errorf("password has expired. Please reset your password")
case "pending_invite":
// User needs to verify invitation code, generate temporary token
var inviteExpire int = 10 * 60 // 10 minutes
// Prepare extra claims to preserve Remember Me state
extraClaims := make(map[string]interface{})
if loginCtx != nil && loginCtx.RememberMe {
extraClaims["remember_me"] = true
}
accessToken, err := oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, ScopeEntryVerification, subject, inviteExpire, extraClaims)
if err != nil {
return nil, err
}
return &LoginResponse{
UserID: userid,
AccessToken: accessToken,
ExpiresIn: inviteExpire,
TokenType: "Bearer",
Scope: ScopeEntryVerification,
Status: LoginStatusInviteVerification,
}, nil
case "active":
// Continue with normal login flow
default:
return nil, fmt.Errorf("account status is invalid: %s", status)
}
// Get MFA enabled status from user data
mfaEnabled := utils.ToBool(user["mfa_enabled"])
// If MFA enabled, generate MFA token
if mfaEnabled {
// Sign temporary access token for MFA
var mfaExpire int = 10 * 60 // 10 minutes
// Prepare extra claims to preserve Remember Me state
extraClaims := make(map[string]interface{})
if loginCtx != nil && loginCtx.RememberMe {
extraClaims["remember_me"] = true
}
accessToken, err := oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, ScopeMFAVerification, subject, mfaExpire, extraClaims)
if err != nil {
return nil, err
}
return &LoginResponse{
UserID: userid,
AccessToken: accessToken,
ExpiresIn: mfaExpire,
MFAEnabled: mfaEnabled,
TokenType: "Bearer",
Scope: ScopeMFAVerification,
Status: LoginStatusMFA,
}, nil
}
// Update Last Login
if loginCtx != nil {
err = userProvider.UpdateUserLastLogin(ctx, userid, loginCtx)
if err != nil {
log.Warn("Failed to update last login: %s", err.Error())
}
}
// Count User Teams
numTeams, err := getUserTeamsCount(ctx, userid)
if err != nil {
return nil, err
}
// If user has teams, return team selection status with temporary access token
if numTeams > 0 {
// Sign temporary access token for Team Selection
var teamSelectionExpire int = 10 * 60 // 10 minutes
// Prepare extra claims to preserve Remember Me state
extraClaims := make(map[string]interface{})
if loginCtx != nil && loginCtx.RememberMe {
extraClaims["remember_me"] = true
}
accessToken, err := oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, ScopeTeamSelection, subject, teamSelectionExpire, extraClaims)
if err != nil {
return nil, err
}
return &LoginResponse{
UserID: userid,
Subject: subject,
AccessToken: accessToken,
ExpiresIn: teamSelectionExpire,
MFAEnabled: mfaEnabled,
TokenType: "Bearer",
Scope: ScopeTeamSelection,
Status: LoginStatusTeamSelection,
}, nil
}
// Issue tokens without team context
return issueTokens(ctx, &IssueTokensParams{
UserID: userid,
TeamID: "",
Team: nil,
Member: nil,
User: user,
Subject: subject,
Scopes: scopes,
LoginCtx: loginCtx,
})
}
// LoginByTeamID is the handler for login by team ID (after team selection)
func LoginByTeamID(userid string, teamID string, loginCtx *LoginContext) (*LoginResponse, error) {
// Get User
userProvider, err := oauth.OAuth.GetUserProvider()
if err != nil {
return nil, err
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Get user data with scopes
user, err := userProvider.GetUserWithScopes(ctx, userid)
if err != nil {
return nil, err
}
yaoClientConfig := GetYaoClientConfig()
var scopes []string = yaoClientConfig.Scopes
if v, ok := user["scopes"].([]string); ok {
scopes = v
}
// Get or create subject
subject, err := oauth.OAuth.Subject(yaoClientConfig.ClientID, userid)
if err != nil {
log.Warn("Failed to store user fingerprint: %s", err.Error())
}
// Handle personal account (no team)
if teamID == "" || teamID == "personal" {
return issueTokens(ctx, &IssueTokensParams{
UserID: userid,
TeamID: "",
Team: nil,
Member: nil,
User: user,
Subject: subject,
Scopes: scopes,
LoginCtx: loginCtx,
})
}
// Verify user is a member of the team and get team details
team, err := userProvider.GetTeamByMember(ctx, teamID, userid)
if err != nil {
return nil, fmt.Errorf("access denied: you are not a member of this team")
}
// Get member profile information for team context
member, err := userProvider.GetMember(ctx, teamID, userid)
if err != nil {
log.Warn("Failed to get member profile: %s", err.Error())
// Continue without member profile if it fails
member = nil
}
// Update Last Login
if loginCtx != nil {
err = userProvider.UpdateUserLastLogin(ctx, userid, loginCtx)
if err != nil {
log.Warn("Failed to update last login: %s", err.Error())
}
}
// Issue tokens with team context and member profile
return issueTokens(ctx, &IssueTokensParams{
UserID: userid,
TeamID: teamID,
Team: team,
Member: member,
User: user,
Subject: subject,
Scopes: scopes,
LoginCtx: loginCtx,
})
}
// issueTokens is the core function that issues all necessary tokens (ID token, access token, refresh token)
func issueTokens(ctx context.Context, params *IssueTokensParams) (*LoginResponse, error) {
yaoClientConfig := GetYaoClientConfig()
// Determine token expiration times based on Remember Me setting
var expiresIn, refreshTokenExpiresIn int
// Try to get token config from entry config first
locale := ""
entryConfig := GetEntryConfig(locale)
if params.LoginCtx != nil && params.LoginCtx.RememberMe {
// Remember Me mode: use extended token durations
if entryConfig != nil && entryConfig.Token != nil {
// Parse Remember Me access token expires_in
if entryConfig.Token.RememberMeExpiresIn != "" {
normalized, err := normalizeDuration(entryConfig.Token.RememberMeExpiresIn)
if err != nil {
log.Warn("Failed to parse remember_me_expires_in: %s, using default", err.Error())
} else {
duration, err := time.ParseDuration(normalized)
if err == nil {
expiresIn = int(duration.Seconds())
}
}
}
// Parse Remember Me refresh token expires_in
if entryConfig.Token.RememberMeRefreshTokenExpiresIn != "" {
normalized, err := normalizeDuration(entryConfig.Token.RememberMeRefreshTokenExpiresIn)
if err != nil {
log.Warn("Failed to parse remember_me_refresh_token_expires_in: %s, using default", err.Error())
} else {
duration, err := time.ParseDuration(normalized)
if err == nil {
refreshTokenExpiresIn = int(duration.Seconds())
}
}
}
// If refresh token not configured, default to 2x the access token duration
if refreshTokenExpiresIn == 0 && expiresIn > 0 {
refreshTokenExpiresIn = expiresIn * 2
}
}
} else {
// Normal login: use standard token durations from entry config
if entryConfig != nil && entryConfig.Token != nil {
// Parse access token expires_in
if entryConfig.Token.ExpiresIn != "" {
normalized, err := normalizeDuration(entryConfig.Token.ExpiresIn)
if err != nil {
log.Warn("Failed to parse expires_in: %s, using default", err.Error())
} else {
duration, err := time.ParseDuration(normalized)
if err == nil {
expiresIn = int(duration.Seconds())
}
}
}
// Parse refresh token expires_in
if entryConfig.Token.RefreshTokenExpiresIn != "" {
normalized, err := normalizeDuration(entryConfig.Token.RefreshTokenExpiresIn)
if err != nil {
log.Warn("Failed to parse refresh_token_expires_in: %s, using default", err.Error())
} else {
duration, err := time.ParseDuration(normalized)
if err == nil {
refreshTokenExpiresIn = int(duration.Seconds())
}
}
}
// If refresh token not configured, default to 24x the access token duration
if refreshTokenExpiresIn == 0 && expiresIn > 0 {
refreshTokenExpiresIn = expiresIn * 24
}
}
}
// Fall back to YaoClientConfig defaults if not set from entry config
if expiresIn == 0 {
expiresIn = yaoClientConfig.ExpiresIn
}
if refreshTokenExpiresIn == 0 {
refreshTokenExpiresIn = yaoClientConfig.RefreshTokenExpiresIn
}
// Prepare OIDC user info
oidcUserInfo := oauthtypes.MakeOIDCUserInfo(params.User)
oidcUserInfo.Sub = params.Subject
oidcUserInfo.YaoUserID = params.UserID // Add original user ID
// Prepare extra claims for access token
extraClaims := make(map[string]interface{})
// Add team context if available
if params.TeamID != "" && params.Team != nil {
extraClaims["team_id"] = params.TeamID
// Add tenant_id if available from the team
if tenantID := utils.ToString(params.Team["tenant_id"]); tenantID != "" {
extraClaims["tenant_id"] = tenantID
oidcUserInfo.YaoTenantID = tenantID
}
// Add team info to OIDC user info
oidcUserInfo.YaoTeamID = params.TeamID
teamInfo := &oauthtypes.OIDCTeamInfo{}
if teamIDVal := utils.ToString(params.Team["team_id"]); teamIDVal != "" {
teamInfo.TeamID = teamIDVal
}
if logo := utils.ToString(params.Team["logo"]); logo != "" {
teamInfo.Logo = logo
}
if name := utils.ToString(params.Team["name"]); name != "" {
teamInfo.Name = name
}
if description := utils.ToString(params.Team["description"]); description != "" {
teamInfo.Description = description
}
// Add owner_id if available from the team (only check once)
if ownerID := utils.ToString(params.Team["owner_id"]); ownerID != "" {
extraClaims["owner_id"] = ownerID
teamInfo.OwnerID = ownerID
// Check if user is owner
if ownerID == params.UserID {
isOwner := true
oidcUserInfo.YaoIsOwner = &isOwner
}
}
oidcUserInfo.YaoTeam = teamInfo
// Add member profile information if available
if params.Member != nil {
memberInfo := &oauthtypes.OIDCMemberInfo{}
if memberID := utils.ToString(params.Member["member_id"]); memberID != "" {
memberInfo.MemberID = memberID
}
if displayName := utils.ToString(params.Member["display_name"]); displayName != "" {
memberInfo.DisplayName = displayName
}
if bio := utils.ToString(params.Member["bio"]); bio != "" {
memberInfo.Bio = bio
}
if avatar := utils.ToString(params.Member["avatar"]); avatar != "" {
memberInfo.Avatar = avatar
}
if email := utils.ToString(params.Member["email"]); email != "" {
memberInfo.Email = email
}
oidcUserInfo.YaoMember = memberInfo
}
}
// Add type information (use team type if in team context, otherwise use user type)
var typeID string
if params.TeamID != "" && params.Team != nil {
// Team context - use team's type
typeID = utils.ToString(params.Team["type_id"])
} else {
// Personal context - use user's type
typeID = utils.ToString(params.User["type_id"])
}
if typeID != "" {
// Add type_id to extra claims for access token
extraClaims["type_id"] = typeID
oidcUserInfo.YaoTypeID = typeID
// Get type details
userProvider, err := oauth.OAuth.GetUserProvider()
if err == nil {
typeInfo, err := userProvider.GetType(ctx, typeID)
if err == nil && typeInfo != nil {
// Add type info to OIDC user info
typeDetails := &oauthtypes.OIDCTypeInfo{}
if typeIDVal := utils.ToString(typeInfo["type_id"]); typeIDVal != "" {
typeDetails.TypeID = typeIDVal
}
if name := utils.ToString(typeInfo["name"]); name != "" {
typeDetails.Name = name
}
if locale := utils.ToString(typeInfo["locale"]); locale != "" {
typeDetails.Locale = locale
}
oidcUserInfo.YaoType = typeDetails
}
}
}
// Sign OIDC Token
var oidcToken string
var err error
if len(extraClaims) > 0 {
oidcToken, err = oauth.OAuth.SignIDToken(yaoClientConfig.ClientID, strings.Join(params.Scopes, " "), expiresIn, oidcUserInfo, extraClaims)
} else {
oidcToken, err = oauth.OAuth.SignIDToken(yaoClientConfig.ClientID, strings.Join(params.Scopes, " "), expiresIn, oidcUserInfo)
}
if err != nil {
return nil, fmt.Errorf("failed to sign OIDC token: %w", err)
}
// Sign Access Token
var accessToken string
if len(extraClaims) > 0 {
accessToken, err = oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, strings.Join(params.Scopes, " "), params.Subject, expiresIn, extraClaims)
} else {
accessToken, err = oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, strings.Join(params.Scopes, " "), params.Subject, expiresIn)
}
if err != nil {
return nil, fmt.Errorf("failed to sign access token: %w", err)
}
// Sign Refresh Token
var refreshToken string
if len(extraClaims) > 0 {
refreshToken, err = oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(params.Scopes, " "), params.Subject, refreshTokenExpiresIn, extraClaims)
} else {
refreshToken, err = oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(params.Scopes, " "), params.Subject, refreshTokenExpiresIn)
}
if err != nil {
return nil, fmt.Errorf("failed to sign refresh token: %w", err)
}
return &LoginResponse{
UserID: params.UserID,
Subject: params.Subject,
AccessToken: accessToken,
IDToken: oidcToken,
RefreshToken: refreshToken,
ExpiresIn: expiresIn,
RefreshTokenExpiresIn: refreshTokenExpiresIn,
TokenType: "Bearer",
MFAEnabled: utils.ToBool(params.User["mfa_enabled"]),
Scope: strings.Join(params.Scopes, " "),
Status: LoginStatusSuccess,
}, nil
}
// generateSessionID generates a session ID
func generateSessionID() string {
return session.ID()
}
// GinLogout handles user logout
func GinLogout(c *gin.Context) {
ctx := c.Request.Context()
// Get access token and refresh token from cookies or headers
// These methods already handle Bearer prefix removal and cookie prefixes
accessToken := oauth.OAuth.GetAccessToken(c)
refreshToken := oauth.OAuth.GetRefreshToken(c)
// Revoke access token if present
if accessToken != "" {
err := oauth.OAuth.Revoke(ctx, accessToken, "access_token")
if err != nil {
log.Warn("Failed to revoke access token during logout: %v", err)
}
}
// Revoke refresh token if present
if refreshToken != "" {
err := oauth.OAuth.Revoke(ctx, refreshToken, "refresh_token")
if err != nil {
log.Warn("Failed to revoke refresh token during logout: %v", err)
}
}
// Clear all authentication cookies
response.DeleteAllAuthCookies(c)
// Return success response
response.RespondWithSuccess(c, http.StatusOK, gin.H{
"message": "Logout successful",
})
}
// SendLoginCookies sends all necessary cookies for a successful login
// This includes access token, refresh token, and optionally session ID cookies with appropriate security settings
func SendLoginCookies(c *gin.Context, loginResponse *LoginResponse, sessionID string) {
// Send session ID cookie only if sessionID is provided
if sessionID != "" {
expires := time.Now().Add(time.Duration(yaoClientConfig.ExpiresIn) * time.Second)
options := response.NewSecureCookieOptions().
WithExpires(expires).
WithSameSite("Strict")
response.SendSecureCookieWithOptions(c, "session_id", sessionID, options)
}
// MFA Temporary Access Token
if loginResponse.Status == LoginStatusMFA {
mfaToken := fmt.Sprintf("Bearer %s", loginResponse.AccessToken)
expires := time.Now().Add(time.Duration(loginResponse.ExpiresIn) * time.Second)
response.SendAccessTokenCookieWithExpiry(c, mfaToken, expires)
return
}
// Normal Access Token
accessToken := fmt.Sprintf("%s %s", loginResponse.TokenType, loginResponse.AccessToken)
refreshToken := fmt.Sprintf("%s %s", loginResponse.TokenType, loginResponse.RefreshToken)
// Calculate expiration times
refreshExpires := time.Now().Add(time.Duration(loginResponse.RefreshTokenExpiresIn) * time.Second)
// Send access token cookie
response.SendAccessTokenCookieWithExpiry(c, accessToken, time.Now().Add(time.Duration(loginResponse.ExpiresIn)*time.Second))
// Send refresh token cookie
response.SendRefreshTokenCookieWithExpiry(c, refreshToken, refreshExpires)
}