package otp import ( "context" "fmt" "net/http" "github.com/gin-gonic/gin" "github.com/yaoapp/gou/session" "github.com/yaoapp/yao/openapi/oauth" "github.com/yaoapp/yao/openapi/oauth/authorized" "github.com/yaoapp/yao/openapi/oauth/types" "github.com/yaoapp/yao/openapi/response" "github.com/yaoapp/yao/openapi/user" "github.com/yaoapp/yao/openapi/utils" ) // OTPCreateRequest is the JSON body for POST /otp/create. type OTPCreateRequest struct { MemberID string `json:"member_id,omitempty"` UserID string `json:"user_id,omitempty"` ExpiresIn int `json:"expires_in,omitempty"` Redirect string `json:"redirect" binding:"required"` Scope string `json:"scope,omitempty"` TokenExpiresIn int `json:"token_expires_in,omitempty"` // access_token lifetime override (seconds) Consume *bool `json:"consume,omitempty"` // revoke code after login; nil means default (true) } // OTPLoginRequest is the JSON body for POST /otp/login. type OTPLoginRequest struct { Code string `json:"code" binding:"required"` Locale string `json:"locale,omitempty"` } // Attach registers OTP HTTP routes on the given router group. // NOTE: /otp/create is disabled — OTP codes should only be created via // server-side Process (otp.Create) to prevent team members from generating // codes for other members and logging in as them. func Attach(group *gin.RouterGroup, auth types.OAuth) { // group.POST("/create", auth.Guard, GinOTPCreate) group.POST("/login", GinOTPLogin) } // GinOTPCreate handles POST /otp/create (protected). // It forces team_id from the caller's identity and validates that the // target member belongs to the same team. func GinOTPCreate(c *gin.Context) { authInfo := authorized.GetInfo(c) if authInfo == nil || authInfo.TeamID == "" { response.RespondWithError(c, http.StatusForbidden, &response.ErrorResponse{ Code: "forbidden", ErrorDescription: "team context is required", }) return } var req OTPCreateRequest if err := c.ShouldBindJSON(&req); err != nil { response.RespondWithError(c, http.StatusBadRequest, &response.ErrorResponse{ Code: response.ErrInvalidRequest.Code, ErrorDescription: err.Error(), }) return } if req.UserID == "" && req.MemberID == "" { response.RespondWithError(c, http.StatusBadRequest, &response.ErrorResponse{ Code: response.ErrInvalidRequest.Code, ErrorDescription: "user_id or member_id is required", }) return } teamID := authInfo.TeamID // Validate that the target member/user belongs to the caller's team if err := validateTeamMembership(teamID, req.UserID, req.MemberID); err != nil { response.RespondWithError(c, http.StatusForbidden, &response.ErrorResponse{ Code: "forbidden", ErrorDescription: err.Error(), }) return } consume := true if req.Consume != nil { consume = *req.Consume } code, err := OTP.Create(&GenerateParams{ TeamID: teamID, MemberID: req.MemberID, UserID: req.UserID, ExpiresIn: req.ExpiresIn, Redirect: req.Redirect, Scope: req.Scope, TokenExpiresIn: req.TokenExpiresIn, Consume: consume, }) if err != nil { response.RespondWithError(c, http.StatusInternalServerError, &response.ErrorResponse{ Code: "server_error", ErrorDescription: err.Error(), }) return } response.RespondWithSuccess(c, http.StatusOK, gin.H{"code": code}) } // GinOTPLogin handles POST /otp/login (public). // Smart login: checks existing session first, issues tokens only when needed. func GinOTPLogin(c *gin.Context) { var req OTPLoginRequest if err := c.ShouldBindJSON(&req); err != nil { response.RespondWithError(c, http.StatusBadRequest, &response.ErrorResponse{ Code: response.ErrInvalidRequest.Code, ErrorDescription: err.Error(), }) return } payload, err := OTP.Verify(req.Code) if err != nil { response.RespondWithError(c, http.StatusUnauthorized, &response.ErrorResponse{ Code: "invalid_otp", ErrorDescription: err.Error(), }) return } var status string // Check if the caller already has a valid session existingToken := oauth.OAuth.GetAccessToken(c) if existingToken != "" { if _, verifyErr := oauth.OAuth.VerifyToken(existingToken); verifyErr == nil { status = "already_logged_in" } } // No valid session: perform OTP login and set cookies if status == "" { result, err := OTP.Login(req.Code, req.Locale) if err != nil { response.RespondWithError(c, http.StatusUnauthorized, &response.ErrorResponse{ Code: "otp_login_failed", ErrorDescription: err.Error(), }) return } sid := utils.GetSessionID(c) if sid == "" { sid = session.ID() } loginResp := &user.LoginResponse{ UserID: result.UserID, Subject: result.Subject, AccessToken: result.AccessToken, IDToken: result.IDToken, RefreshToken: result.RefreshToken, ExpiresIn: result.ExpiresIn, RefreshTokenExpiresIn: result.RefreshTokenExpiresIn, TokenType: result.TokenType, Scope: result.Scope, Status: user.LoginStatusSuccess, } user.SendLoginCookies(c, loginResp, sid) status = "success" } // Consume OTP code if configured if payload.Consume { _ = OTP.Revoke(req.Code) } response.RespondWithSuccess(c, http.StatusOK, gin.H{ "status": status, "redirect": payload.Redirect, }) } // validateTeamMembership checks that the given user or member belongs to the specified team. func validateTeamMembership(teamID, userID, memberID string) error { userProvider, err := oauth.OAuth.GetUserProvider() if err != nil { return fmt.Errorf("failed to get user provider: %w", err) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() if memberID != "" { member, err := userProvider.GetMemberByMemberID(ctx, memberID) if err != nil { return fmt.Errorf("member not found: %s", memberID) } memberTeam := utils.ToString(member["team_id"]) if memberTeam != teamID { return fmt.Errorf("member %s does not belong to team %s", memberID, teamID) } return nil } if userID != "" { _, err := userProvider.GetMember(ctx, teamID, userID) if err != nil { return fmt.Errorf("user %s is not a member of team %s", userID, teamID) } return nil } return fmt.Errorf("user_id or member_id is required") }