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:
parent
e7bb997e2a
commit
4d22a9a655
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)
|
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", getEntryConfig) // Get unified auth entry config (public)
|
||||||
group.GET("/entry/captcha", getCaptcha) // Get captcha for login/register (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("/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
|
// Register a new user
|
||||||
group.POST("/entry/register", oauth.Guard, GinEntryRegister) // Register a new user
|
group.POST("/entry/register", oauth.Guard, GinEntryRegister) // Register a new user
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue