Merge pull request #1524 from trheyi/main
feat(user): add token-based login endpoint for automation and testing
This commit is contained in:
commit
936d104d31
3 changed files with 214 additions and 0 deletions
126
openapi/oauth/process.go
Normal file
126
openapi/oauth/process.go
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue