- Implemented GinTokenLogin function to handle POST requests for token-based login. - Allows users to authenticate using a pre-signed access token, returning session cookies and user info. - Updated user routes to include the new /token/login endpoint for public access.
1128 lines
35 KiB
Go
1128 lines
35 KiB
Go
package user
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/yaoapp/gou/session"
|
|
"github.com/yaoapp/kun/log"
|
|
"github.com/yaoapp/kun/maps"
|
|
"github.com/yaoapp/yao/agent/assistant"
|
|
"github.com/yaoapp/yao/kb"
|
|
kbapi "github.com/yaoapp/yao/kb/api"
|
|
"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"
|
|
)
|
|
|
|
// kbCollectionCreating tracks collections currently being created to avoid duplicate creation
|
|
var kbCollectionCreating sync.Map
|
|
|
|
// registerUserWithTeam creates a new user and automatically creates a default team.
|
|
// If team creation fails, the user is rolled back (deleted) to ensure data consistency.
|
|
// This is the single entry point for all user registration paths (email/mobile, OAuth third-party, etc.).
|
|
//
|
|
// Parameters:
|
|
// - ctx: context for database operations
|
|
// - userData: user fields to pass to CreateUser (name, email, status, role_id, type_id, etc.)
|
|
// - locale: user's locale for determining default team name (e.g. "zh-cn", "en")
|
|
//
|
|
// Returns:
|
|
// - userID: the created user's ID
|
|
// - error: non-nil if user creation or team creation failed (user is rolled back on team failure)
|
|
func registerUserWithTeam(ctx context.Context, userData map[string]interface{}, locale string) (string, error) {
|
|
userProvider, err := oauth.OAuth.GetUserProvider()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get user provider: %w", err)
|
|
}
|
|
|
|
// Create user
|
|
userID, err := userProvider.CreateUser(ctx, userData)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create user: %w", err)
|
|
}
|
|
|
|
// Auto-create a default team for the new user
|
|
// Use "<DisplayName>'s Team" / "<DisplayName>的团队" format
|
|
// Priority: given_name > name (given_name is more natural as display name)
|
|
userName := ""
|
|
if v, ok := userData["given_name"].(string); ok && v != "" {
|
|
userName = v
|
|
} else if v, ok := userData["name"].(string); ok && v != "" {
|
|
userName = v
|
|
}
|
|
var defaultTeamName string
|
|
if strings.HasPrefix(strings.ToLower(locale), "zh") {
|
|
if userName != "" {
|
|
defaultTeamName = userName + "的团队"
|
|
} else {
|
|
defaultTeamName = "我的团队"
|
|
}
|
|
} else {
|
|
if userName != "" {
|
|
defaultTeamName = userName + "'s Team"
|
|
} else {
|
|
defaultTeamName = "My Team"
|
|
}
|
|
}
|
|
teamData := maps.MapStrAny{
|
|
"name": defaultTeamName,
|
|
"locale": locale,
|
|
}
|
|
defaultTeamID, err := teamCreate(ctx, userID, teamData)
|
|
if err != nil {
|
|
log.Error("Failed to create default team for user %s: %v", userID, err)
|
|
// Rollback: delete the created user since a team is required
|
|
if delErr := userProvider.DeleteUser(ctx, userID); delErr != nil {
|
|
log.Error("Failed to rollback user %s after team creation failure: %v", userID, delErr)
|
|
}
|
|
return "", fmt.Errorf("registration failed: unable to initialize team: %w", err)
|
|
}
|
|
|
|
log.Info("User registered: %s, default team: %s", userID, defaultTeamID)
|
|
return userID, nil
|
|
}
|
|
|
|
// 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/")
|
|
}
|
|
}
|
|
|
|
// Auto register user if not exists
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
userProvider, err := oauth.OAuth.GetUserProvider()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var userID string
|
|
|
|
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,
|
|
}
|
|
|
|
// Register user with default team (with rollback on failure)
|
|
userID, err = registerUserWithTeam(ctx, userData, locale)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Create OAuth account link
|
|
oauthData := userinfo.Map()
|
|
oauthData["provider"] = providerID
|
|
_, err = userProvider.CreateOAuthAccount(ctx, userID, oauthData)
|
|
if err != nil {
|
|
// Rollback: delete user and team if OAuth account creation fails
|
|
if delErr := userProvider.DeleteUser(ctx, userID); delErr != nil {
|
|
log.Error("Failed to rollback user %s after OAuth account creation failure: %v", userID, delErr)
|
|
}
|
|
return nil, fmt.Errorf("failed to create OAuth account: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get User ID from OAuth account
|
|
userID, err = userProvider.GetOAuthUserID(ctx, providerID, userinfo.Sub)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Pass OAuth email to loginCtx for display in token (without polluting user profile email)
|
|
if loginCtx != nil && userinfo.Email != "" {
|
|
loginCtx.OAuthEmail = userinfo.Email
|
|
}
|
|
|
|
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 exactly one team, auto-select it and skip team selection page
|
|
if numTeams == 1 {
|
|
teams, err := getUserTeams(ctx, userid)
|
|
if err == nil && len(teams) == 1 {
|
|
teamID := ""
|
|
if v, ok := teams[0]["team_id"].(string); ok {
|
|
teamID = v
|
|
}
|
|
if teamID != "" {
|
|
return LoginByTeamID(userid, teamID, loginCtx)
|
|
}
|
|
}
|
|
// Fall through to team selection if we couldn't auto-select
|
|
}
|
|
|
|
// If user has multiple teams, return team selection status with temporary access token
|
|
if numTeams > 1 {
|
|
// Sign temporary access token for Team Selection
|
|
var teamSelectionExpire int = 10 * 60 // 10 minutes
|
|
|
|
// Prepare extra claims to preserve Remember Me and AuthSource state
|
|
extraClaims := make(map[string]interface{})
|
|
if loginCtx != nil && loginCtx.RememberMe {
|
|
extraClaims["remember_me"] = true
|
|
}
|
|
if loginCtx != nil && loginCtx.AuthSource != "" {
|
|
extraClaims["auth_source"] = loginCtx.AuthSource
|
|
}
|
|
if loginCtx != nil && loginCtx.OAuthEmail != "" {
|
|
extraClaims["oauth_email"] = loginCtx.OAuthEmail
|
|
}
|
|
|
|
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
|
|
resp, err := issueTokens(ctx, &IssueTokensParams{
|
|
UserID: userid,
|
|
TeamID: "",
|
|
Team: nil,
|
|
Member: nil,
|
|
User: user,
|
|
Subject: subject,
|
|
Scopes: scopes,
|
|
LoginCtx: loginCtx,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Initialize KB collection asynchronously after successful login
|
|
locale := ""
|
|
if loginCtx != nil {
|
|
locale = loginCtx.Locale
|
|
}
|
|
go prepareUserKBCollection(userid, "", locale)
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// 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) - deprecated, all users should use teams
|
|
if teamID == "" || teamID == "personal" {
|
|
log.Warn("Personal account login is deprecated. User %s should select a team.", userid)
|
|
resp, err := issueTokens(ctx, &IssueTokensParams{
|
|
UserID: userid,
|
|
TeamID: "",
|
|
Team: nil,
|
|
Member: nil,
|
|
User: user,
|
|
Subject: subject,
|
|
Scopes: scopes,
|
|
LoginCtx: loginCtx,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Initialize KB collection asynchronously after successful login
|
|
locale := ""
|
|
if loginCtx != nil {
|
|
locale = loginCtx.Locale
|
|
}
|
|
go prepareUserKBCollection(userid, "", locale)
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// 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
|
|
resp, err := issueTokens(ctx, &IssueTokensParams{
|
|
UserID: userid,
|
|
TeamID: teamID,
|
|
Team: team,
|
|
Member: member,
|
|
User: user,
|
|
Subject: subject,
|
|
Scopes: scopes,
|
|
LoginCtx: loginCtx,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Initialize KB collection asynchronously after successful login
|
|
locale := ""
|
|
if loginCtx != nil {
|
|
locale = loginCtx.Locale
|
|
}
|
|
go prepareUserKBCollection(userid, teamID, locale)
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// LoginWithOptions performs the same login flow as LoginByTeamID but allows
|
|
// overriding scopes via opts. When opts.Scopes is non-nil, those scopes are
|
|
// used instead of the user/client defaults.
|
|
func LoginWithOptions(userid string, teamID string, loginCtx *LoginContext, opts *LoginOptions) (*LoginResponse, error) {
|
|
if opts == nil {
|
|
return LoginByTeamID(userid, teamID, loginCtx)
|
|
}
|
|
|
|
hasOverrides := opts.Scopes != nil || opts.TokenExpiresIn > 0 || opts.SkipRefreshToken
|
|
if !hasOverrides {
|
|
return LoginByTeamID(userid, teamID, loginCtx)
|
|
}
|
|
|
|
userProvider, err := oauth.OAuth.GetUserProvider()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
userData, err := userProvider.GetUserWithScopes(ctx, userid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Resolve scopes: use opts.Scopes when provided, otherwise fall back to
|
|
// the same default resolution as LoginByTeamID (user scopes or client config).
|
|
scopes := opts.Scopes
|
|
if scopes == nil {
|
|
yaoClientConfig := GetYaoClientConfig()
|
|
scopes = yaoClientConfig.Scopes
|
|
if v, ok := userData["scopes"].([]string); ok {
|
|
scopes = v
|
|
}
|
|
}
|
|
|
|
subject, err := oauth.OAuth.Subject(GetYaoClientConfig().ClientID, userid)
|
|
if err != nil {
|
|
log.Warn("Failed to store user fingerprint: %s", err.Error())
|
|
}
|
|
|
|
if teamID == "" || teamID == "personal" {
|
|
resp, err := issueTokens(ctx, &IssueTokensParams{
|
|
UserID: userid,
|
|
TeamID: "",
|
|
Team: nil,
|
|
Member: nil,
|
|
User: userData,
|
|
Subject: subject,
|
|
Scopes: scopes,
|
|
LoginCtx: loginCtx,
|
|
TokenExpiresIn: opts.TokenExpiresIn,
|
|
SkipRefreshToken: opts.SkipRefreshToken,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
locale := ""
|
|
if loginCtx != nil {
|
|
locale = loginCtx.Locale
|
|
}
|
|
go prepareUserKBCollection(userid, "", locale)
|
|
return resp, nil
|
|
}
|
|
|
|
team, err := userProvider.GetTeamByMember(ctx, teamID, userid)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("access denied: you are not a member of this team")
|
|
}
|
|
|
|
member, err := userProvider.GetMember(ctx, teamID, userid)
|
|
if err != nil {
|
|
log.Warn("Failed to get member profile: %s", err.Error())
|
|
member = nil
|
|
}
|
|
|
|
if loginCtx != nil {
|
|
err = userProvider.UpdateUserLastLogin(ctx, userid, loginCtx)
|
|
if err != nil {
|
|
log.Warn("Failed to update last login: %s", err.Error())
|
|
}
|
|
}
|
|
|
|
resp, err := issueTokens(ctx, &IssueTokensParams{
|
|
UserID: userid,
|
|
TeamID: teamID,
|
|
Team: team,
|
|
Member: member,
|
|
User: userData,
|
|
Subject: subject,
|
|
Scopes: scopes,
|
|
LoginCtx: loginCtx,
|
|
TokenExpiresIn: opts.TokenExpiresIn,
|
|
SkipRefreshToken: opts.SkipRefreshToken,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
locale := ""
|
|
if loginCtx != nil {
|
|
locale = loginCtx.Locale
|
|
}
|
|
go prepareUserKBCollection(userid, teamID, locale)
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// 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()
|
|
|
|
// Token expiration strategy:
|
|
// - access_token: always short-lived (from expires_in config), same for all login types
|
|
// - refresh_token: short for normal login, long for remember_me / OAuth
|
|
// Security: a leaked access_token has limited impact window; "keep logged in"
|
|
// is achieved by silently refreshing via long-lived refresh_token in Guard.
|
|
var expiresIn, refreshTokenExpiresIn int
|
|
|
|
locale := ""
|
|
if params.LoginCtx != nil && params.LoginCtx.Locale != "" {
|
|
locale = params.LoginCtx.Locale
|
|
}
|
|
entryConfig := GetEntryConfig(locale)
|
|
|
|
// 1. Access token: always use the standard short duration
|
|
if entryConfig != nil && entryConfig.Token != nil && 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())
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Refresh token: depends on remember_me
|
|
rememberMe := params.LoginCtx != nil && params.LoginCtx.RememberMe
|
|
if rememberMe && entryConfig != nil && entryConfig.Token != nil {
|
|
// Remember Me: use extended refresh token duration
|
|
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())
|
|
}
|
|
}
|
|
}
|
|
} else if entryConfig != nil && entryConfig.Token != nil {
|
|
// Normal login: use standard refresh token duration
|
|
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())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Default fallbacks
|
|
if expiresIn == 0 {
|
|
expiresIn = yaoClientConfig.ExpiresIn
|
|
}
|
|
// Refresh token defaults: remember_me 90d, normal 7d, then client config
|
|
if refreshTokenExpiresIn == 0 {
|
|
if rememberMe {
|
|
refreshTokenExpiresIn = 90 * 24 * 3600 // 90 days
|
|
} else if yaoClientConfig.RefreshTokenExpiresIn > 0 {
|
|
refreshTokenExpiresIn = yaoClientConfig.RefreshTokenExpiresIn
|
|
} else {
|
|
refreshTokenExpiresIn = 7 * 24 * 3600 // 7 days
|
|
}
|
|
}
|
|
|
|
// 4. Caller overrides (e.g. OTP login with custom token lifetime)
|
|
if params.TokenExpiresIn > 0 {
|
|
expiresIn = params.TokenExpiresIn
|
|
}
|
|
|
|
// Prepare OIDC user info
|
|
oidcUserInfo := oauthtypes.MakeOIDCUserInfo(params.User)
|
|
oidcUserInfo.Sub = params.Subject
|
|
oidcUserInfo.YaoUserID = params.UserID // Add original user ID
|
|
|
|
// Add authentication source from IssueTokensParams or LoginContext
|
|
if params.AuthSource != "" {
|
|
oidcUserInfo.YaoAuthSource = params.AuthSource
|
|
} else if params.LoginCtx != nil && params.LoginCtx.AuthSource != "" {
|
|
oidcUserInfo.YaoAuthSource = params.LoginCtx.AuthSource
|
|
}
|
|
|
|
// For third-party login: use OAuth email (masked) if user profile email is empty
|
|
if oidcUserInfo.YaoAuthSource != "" && oidcUserInfo.YaoAuthSource != "password" {
|
|
if oidcUserInfo.Email == "" && params.LoginCtx != nil && params.LoginCtx.OAuthEmail != "" {
|
|
oidcUserInfo.Email = params.LoginCtx.OAuthEmail
|
|
}
|
|
if oidcUserInfo.Email != "" {
|
|
oidcUserInfo.Email = MaskEmail(oidcUserInfo.Email)
|
|
}
|
|
}
|
|
|
|
// 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 (skip for temporary sessions like OTP)
|
|
var refreshToken string
|
|
if params.SkipRefreshToken {
|
|
refreshTokenExpiresIn = 0
|
|
} else {
|
|
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
|
|
}
|
|
|
|
// prepareUserKBCollection prepares KB collection for user (called asynchronously after login)
|
|
func prepareUserKBCollection(userID, teamID, locale string) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Warn("prepareUserKBCollection recovered from panic: %v", r)
|
|
}
|
|
}()
|
|
|
|
// Get global KB setting
|
|
kbSetting := assistant.GetGlobalKBSetting()
|
|
if kbSetting == nil || kbSetting.Chat == nil {
|
|
return // No KB configuration for chat, skip
|
|
}
|
|
|
|
// Check if KB API is initialized
|
|
if kb.API == nil {
|
|
log.Warn("KB API not initialized, skipping KB collection preparation")
|
|
return
|
|
}
|
|
|
|
chatKB := kbSetting.Chat
|
|
|
|
// Get KB collection ID for this user
|
|
// Same team + user always produces the same ID (idempotent)
|
|
collectionID := assistant.GetChatKBID(teamID, userID)
|
|
|
|
// Check if this collection is currently being created by another goroutine
|
|
if _, isCreating := kbCollectionCreating.LoadOrStore(collectionID, true); isCreating {
|
|
return
|
|
}
|
|
// Ensure cleanup even if panic occurs
|
|
defer kbCollectionCreating.Delete(collectionID)
|
|
|
|
// Check if collection already exists
|
|
ctx := context.Background()
|
|
existsResult, err := kb.API.CollectionExists(ctx, collectionID)
|
|
if err != nil {
|
|
// If check fails, log and continue to create (let create handle conflicts)
|
|
log.Warn("failed to check collection existence: %v, will attempt to create", err)
|
|
} else if existsResult != nil && existsResult.Exists {
|
|
// Collection exists, no need to create
|
|
return
|
|
}
|
|
|
|
// Build metadata
|
|
metadata := make(map[string]interface{})
|
|
for k, v := range chatKB.Metadata {
|
|
metadata[k] = v
|
|
}
|
|
metadata["team_id"] = teamID
|
|
metadata["user_id"] = userID
|
|
|
|
// Ensure name and description are set (required fields)
|
|
// Use user's locale from login context to determine language
|
|
isZh := strings.HasPrefix(strings.ToLower(locale), "zh")
|
|
if _, exists := metadata["name"]; !exists {
|
|
if isZh {
|
|
metadata["name"] = "对话知识库"
|
|
} else {
|
|
metadata["name"] = "Chat Knowledge Base"
|
|
}
|
|
}
|
|
if _, exists := metadata["description"]; !exists {
|
|
if isZh {
|
|
metadata["description"] = "用户对话知识库"
|
|
} else {
|
|
metadata["description"] = "User chat knowledge base"
|
|
}
|
|
}
|
|
|
|
// Build auth scope (use __yao_ prefix for permission fields)
|
|
// Only set __yao_created_by for create operations (consistent with WithCreateScope)
|
|
authScope := make(map[string]interface{})
|
|
if teamID != "" {
|
|
authScope["__yao_team_id"] = teamID
|
|
}
|
|
authScope["__yao_created_by"] = userID
|
|
|
|
// Create new collection for this user
|
|
createParams := &kbapi.CreateCollectionParams{
|
|
ID: collectionID,
|
|
EmbeddingProviderID: chatKB.EmbeddingProviderID,
|
|
EmbeddingOptionID: chatKB.EmbeddingOptionID,
|
|
Locale: chatKB.Locale,
|
|
Config: chatKB.Config,
|
|
Metadata: metadata,
|
|
AuthScope: authScope,
|
|
}
|
|
|
|
_, err = kb.API.CreateCollection(ctx, createParams)
|
|
if err != nil {
|
|
log.Warn("failed to create KB collection for user %s: %v", userID, err)
|
|
return
|
|
}
|
|
|
|
log.Info("Created KB collection: %s for team=%s, user=%s", collectionID, teamID, userID)
|
|
}
|
|
|
|
// 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 - expires with refresh token so session survives token refreshes
|
|
if sessionID != "" {
|
|
sessionExpiry := loginResponse.RefreshTokenExpiresIn
|
|
if sessionExpiry <= 0 {
|
|
sessionExpiry = loginResponse.ExpiresIn
|
|
}
|
|
expires := time.Now().Add(time.Duration(sessionExpiry) * 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)
|
|
|
|
if loginResponse.RefreshToken != "" {
|
|
refreshToken := fmt.Sprintf("%s %s", loginResponse.TokenType, loginResponse.RefreshToken)
|
|
// access_token cookie lives as long as refresh_token so the browser keeps sending the
|
|
// (JWT-expired) access token — the Guard can then use the refresh token to issue a new one.
|
|
refreshExpires := time.Now().Add(time.Duration(loginResponse.RefreshTokenExpiresIn) * time.Second)
|
|
response.SendAccessTokenCookieWithExpiry(c, accessToken, refreshExpires)
|
|
response.SendRefreshTokenCookieWithExpiry(c, refreshToken, refreshExpires)
|
|
} else {
|
|
// No refresh token (e.g. OTP login): cookie expires with the access token
|
|
accessExpires := time.Now().Add(time.Duration(loginResponse.ExpiresIn) * time.Second)
|
|
response.SendAccessTokenCookieWithExpiry(c, accessToken, accessExpires)
|
|
}
|
|
}
|
|
|
|
// GinTokenLogin handles POST /user/token/login.
|
|
// Accepts a pre-signed access token (e.g. from oauth.token.Make), verifies it,
|
|
// issues a full session (cookies + id_token), and returns user info for CUI AfterLogin.
|
|
// Designed for automation / testing scenarios where interactive login is not practical.
|
|
func GinTokenLogin(c *gin.Context) {
|
|
// Read token from Authorization header or JSON body
|
|
token := c.GetHeader("Authorization")
|
|
if token != "" {
|
|
token = strings.TrimPrefix(token, "Bearer ")
|
|
}
|
|
if token == "" {
|
|
var body struct {
|
|
Token string `json:"token"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err == nil {
|
|
token = body.Token
|
|
}
|
|
}
|
|
if token == "" {
|
|
response.RespondWithError(c, http.StatusBadRequest, &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "access token is required (Authorization header or JSON body)",
|
|
})
|
|
return
|
|
}
|
|
|
|
if oauth.OAuth == nil {
|
|
response.RespondWithError(c, http.StatusInternalServerError, &response.ErrorResponse{
|
|
Code: "server_error",
|
|
ErrorDescription: "oauth service not initialized",
|
|
})
|
|
return
|
|
}
|
|
|
|
claims, err := oauth.OAuth.VerifyToken(token)
|
|
if err != nil {
|
|
response.RespondWithError(c, http.StatusUnauthorized, &response.ErrorResponse{
|
|
Code: "invalid_token",
|
|
ErrorDescription: fmt.Sprintf("token verification failed: %v", err),
|
|
})
|
|
return
|
|
}
|
|
|
|
subject := claims.Subject
|
|
if subject == "" {
|
|
response.RespondWithError(c, http.StatusUnauthorized, &response.ErrorResponse{
|
|
Code: "invalid_token",
|
|
ErrorDescription: "token has no subject claim",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Look up user by the extra user_id claim first, fall back to subject
|
|
userID := subject
|
|
if claims.Extra != nil {
|
|
if uid, ok := claims.Extra["user_id"].(string); ok && uid != "" {
|
|
userID = uid
|
|
}
|
|
}
|
|
|
|
teamID := claims.TeamID
|
|
|
|
// Delegate to the standard login path which handles scopes, subject,
|
|
// team/member lookup, and all other details correctly.
|
|
var loginResp *LoginResponse
|
|
if teamID != "" {
|
|
loginResp, err = LoginByTeamID(userID, teamID, nil)
|
|
} else {
|
|
loginResp, err = LoginByUserID(userID, nil)
|
|
}
|
|
if err != nil {
|
|
response.RespondWithError(c, http.StatusInternalServerError, &response.ErrorResponse{
|
|
Code: "server_error",
|
|
ErrorDescription: fmt.Sprintf("login failed: %v", err),
|
|
})
|
|
return
|
|
}
|
|
|
|
sid := session.ID()
|
|
SendLoginCookies(c, loginResp, sid)
|
|
|
|
response.RespondWithSuccess(c, http.StatusOK, gin.H{
|
|
"status": "success",
|
|
"id_token": loginResp.IDToken,
|
|
})
|
|
}
|