feat(user): add token-based login endpoint for automation and testing

- 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.
This commit is contained in:
Max 2026-04-22 14:12:53 +08:00
parent e7bb997e2a
commit 4d22a9a655
3 changed files with 214 additions and 0 deletions

126
openapi/oauth/process.go Normal file
View file

@ -0,0 +1,126 @@
package oauth
import (
"context"
"fmt"
"github.com/yaoapp/gou/process"
"github.com/yaoapp/kun/exception"
)
func init() {
process.RegisterGroup("oauth", map[string]process.Handler{
"token.Make": processTokenMake,
"token.MakeByUser": processTokenMakeByUser,
})
}
// processTokenMake generates an OAuth access token with explicit parameters.
//
// Args:
//
// [0] clientID string OAuth client ID embedded in the token
// [1] scope string token scope (space-separated)
// [2] subject string JWT subject claim
// [3] expiresIn int token lifetime in seconds
// [4] extraClaims map (optional) additional JWT claims (e.g. user_id, team_id)
//
// Returns: token string
//
// Example: Process("oauth.token.Make", "tai-agent-smith", "tai:tunnel", "ci-tai", 86400)
func processTokenMake(p *process.Process) interface{} {
p.ValidateArgNums(4)
clientID := p.ArgsString(0)
scope := p.ArgsString(1)
subject := p.ArgsString(2)
expiresIn := p.ArgsInt(3)
if OAuth == nil {
exception.New("oauth service not initialized", 500).Throw()
}
var extraClaims map[string]interface{}
if p.NumOfArgs() > 4 {
if claims, ok := p.Args[4].(map[string]interface{}); ok {
extraClaims = claims
}
}
token, err := OAuth.MakeAccessToken(clientID, scope, subject, expiresIn, extraClaims)
if err != nil {
exception.New(fmt.Sprintf("oauth.token.Make: %v", err), 500).Throw()
}
return token
}
// processTokenMakeByUser generates an OAuth access token for a team member.
// Looks up user and team from the database, automatically filling clientID, scope, subject, and claims.
//
// Args:
//
// [0] teamID string team ID
// [1] memberID string member ID (business ID)
// [2] expiresIn int token lifetime in seconds (optional, default 86400 = 24h)
//
// Returns: token string
//
// Example: Process("oauth.token.MakeByUser", "team-abc", "member-xyz")
// Example: Process("oauth.token.MakeByUser", "team-abc", "member-xyz", 3600)
func processTokenMakeByUser(p *process.Process) interface{} {
p.ValidateArgNums(2)
teamID := p.ArgsString(0)
memberID := p.ArgsString(1)
expiresIn := 86400 // default 24h
if p.NumOfArgs() > 2 {
if v := p.ArgsInt(2); v > 0 {
expiresIn = v
}
}
if OAuth == nil {
exception.New("oauth service not initialized", 500).Throw()
}
userProvider, err := OAuth.GetUserProvider()
if err != nil {
exception.New(fmt.Sprintf("oauth.token.MakeByUser: failed to get user provider: %v", err), 500).Throw()
}
ctx := context.Background()
member, err := userProvider.GetMemberByMemberID(ctx, memberID)
if err != nil {
exception.New(fmt.Sprintf("oauth.token.MakeByUser: member not found: %v", err), 404).Throw()
}
memberTeamID := ""
if v, ok := member["team_id"].(string); ok {
memberTeamID = v
}
if memberTeamID != teamID {
exception.New(fmt.Sprintf("oauth.token.MakeByUser: member %s does not belong to team %s", memberID, teamID), 403).Throw()
}
userID := ""
if v, ok := member["user_id"].(string); ok {
userID = v
}
if userID == "" {
exception.New(fmt.Sprintf("oauth.token.MakeByUser: member %s has no user_id", memberID), 500).Throw()
}
subject := userID
extraClaims := map[string]interface{}{
"user_id": userID,
"team_id": teamID,
"member_id": memberID,
}
token, err := OAuth.MakeAccessToken("yao-admin", "openid profile", subject, expiresIn, extraClaims)
if err != nil {
exception.New(fmt.Sprintf("oauth.token.MakeByUser: %v", err), 500).Throw()
}
return token
}

View file

@ -1039,3 +1039,90 @@ func SendLoginCookies(c *gin.Context, loginResponse *LoginResponse, sessionID st
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,
})
}

View file

@ -93,6 +93,7 @@ func Attach(group *gin.RouterGroup, oauth types.OAuth) {
group.GET("/entry", getEntryConfig) // Get unified auth entry config (public)
group.GET("/entry/captcha", getCaptcha) // Get captcha for login/register (public)
group.POST("/entry/verify", GinEntryVerify) // Verify login/register email or mobile (public)
group.POST("/token/login", GinTokenLogin) // Token login for automation/testing (public)
// Register a new user
group.POST("/entry/register", oauth.Guard, GinEntryRegister) // Register a new user