- Updated captcha test cases to utilize the new CaptchaGet function for retrieving captcha answers, improving test reliability. - Refactored CaptchaMake and CaptchaValidate functions to leverage a new utils package for better encapsulation and maintainability. - Enhanced error handling and logging in captcha-related processes, contributing to a more robust user experience during authentication. - Streamlined the captcha generation process by integrating with the utils.captcha package, ensuring consistency across the codebase.
435 lines
13 KiB
Go
435 lines
13 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/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 {
|
|
|
|
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": "active",
|
|
}
|
|
|
|
// 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())
|
|
}
|
|
|
|
// Get MFA enabled status from user data
|
|
mfaEnabled := 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
|
|
accessToken, err := oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, ScopeMFAVerification, subject, mfaExpire)
|
|
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
|
|
accessToken, err := oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, ScopeTeamSelection, subject, teamSelectionExpire)
|
|
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, userid, "", nil, user, subject, scopes)
|
|
}
|
|
|
|
// 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, userid, "", nil, user, subject, scopes)
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
|
|
// 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
|
|
return issueTokens(ctx, userid, teamID, team, user, subject, scopes)
|
|
}
|
|
|
|
// issueTokens is the core function that issues all necessary tokens (ID token, access token, refresh token)
|
|
func issueTokens(ctx context.Context, userid string, teamID string, team map[string]interface{}, user map[string]interface{}, subject string, scopes []string) (*LoginResponse, error) {
|
|
yaoClientConfig := GetYaoClientConfig()
|
|
|
|
// Prepare OIDC user info
|
|
oidcUserInfo := oauthtypes.MakeOIDCUserInfo(user)
|
|
oidcUserInfo.Sub = subject
|
|
oidcUserInfo.YaoUserID = userid // Add original user ID
|
|
|
|
// Prepare extra claims for access token
|
|
extraClaims := make(map[string]interface{})
|
|
|
|
// Add team context if available
|
|
if teamID != "" && team != nil {
|
|
extraClaims["team_id"] = teamID
|
|
|
|
// Add tenant_id if available from the team
|
|
if tenantID := toString(team["tenant_id"]); tenantID != "" {
|
|
extraClaims["tenant_id"] = tenantID
|
|
oidcUserInfo.YaoTenantID = tenantID
|
|
}
|
|
|
|
// Add team info to OIDC user info
|
|
oidcUserInfo.YaoTeamID = teamID
|
|
teamInfo := &oauthtypes.OIDCTeamInfo{}
|
|
if teamIDVal := toString(team["team_id"]); teamIDVal != "" {
|
|
teamInfo.TeamID = teamIDVal
|
|
}
|
|
if logo := toString(team["logo"]); logo != "" {
|
|
teamInfo.Logo = logo
|
|
}
|
|
if name := toString(team["name"]); name != "" {
|
|
teamInfo.Name = name
|
|
}
|
|
if description := toString(team["description"]); description != "" {
|
|
teamInfo.Description = description
|
|
}
|
|
|
|
// Add owner_id if available from the team (only check once)
|
|
if ownerID := toString(team["owner_id"]); ownerID != "" {
|
|
extraClaims["owner_id"] = ownerID
|
|
teamInfo.OwnerID = ownerID
|
|
|
|
// Check if user is owner
|
|
if ownerID == userid {
|
|
isOwner := true
|
|
oidcUserInfo.YaoIsOwner = &isOwner
|
|
}
|
|
}
|
|
|
|
oidcUserInfo.YaoTeam = teamInfo
|
|
}
|
|
|
|
// Add type information (use team type if in team context, otherwise use user type)
|
|
var typeID string
|
|
if teamID != "" && team != nil {
|
|
// Team context - use team's type
|
|
typeID = toString(team["type_id"])
|
|
} else {
|
|
// Personal context - use user's type
|
|
typeID = toString(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 := toString(typeInfo["type_id"]); typeIDVal != "" {
|
|
typeDetails.TypeID = typeIDVal
|
|
}
|
|
if name := toString(typeInfo["name"]); name != "" {
|
|
typeDetails.Name = name
|
|
}
|
|
if locale := 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(scopes, " "), yaoClientConfig.ExpiresIn, oidcUserInfo, extraClaims)
|
|
} else {
|
|
oidcToken, err = oauth.OAuth.SignIDToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), yaoClientConfig.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(scopes, " "), subject, yaoClientConfig.ExpiresIn, extraClaims)
|
|
} else {
|
|
accessToken, err = oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, yaoClientConfig.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(scopes, " "), subject, yaoClientConfig.RefreshTokenExpiresIn, extraClaims)
|
|
} else {
|
|
refreshToken, err = oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, yaoClientConfig.RefreshTokenExpiresIn)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to sign refresh token: %w", err)
|
|
}
|
|
|
|
return &LoginResponse{
|
|
UserID: userid,
|
|
Subject: subject,
|
|
AccessToken: accessToken,
|
|
IDToken: oidcToken,
|
|
RefreshToken: refreshToken,
|
|
ExpiresIn: yaoClientConfig.ExpiresIn,
|
|
RefreshTokenExpiresIn: yaoClientConfig.RefreshTokenExpiresIn,
|
|
TokenType: "Bearer",
|
|
MFAEnabled: toBool(user["mfa_enabled"]),
|
|
Scope: strings.Join(scopes, " "),
|
|
Status: LoginStatusSuccess,
|
|
}, nil
|
|
}
|
|
|
|
// generateSessionID generates a session ID
|
|
func generateSessionID() string {
|
|
return session.ID()
|
|
}
|
|
|
|
// 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)
|
|
}
|