fix: harden auth flows and align api contracts
This commit is contained in:
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -35,14 +36,14 @@ type AuthorizeRequest struct {
|
||||
|
||||
// Authorize 处理 SSO 授权请求
|
||||
// @Summary SSO 授权
|
||||
// @Description 处理 SSO 授权请求,返回授权码或访问令牌
|
||||
// @Description 处理 SSO 授权请求,返回授权码
|
||||
// @Tags SSO
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param client_id query string true "客户端ID"
|
||||
// @Param redirect_uri query string true "回调地址"
|
||||
// @Param response_type query string true "响应类型" Enums(code, token)
|
||||
// @Param response_type query string true "响应类型" Enums(code)
|
||||
// @Param scope query string false "授权范围"
|
||||
// @Param state query string false "状态参数"
|
||||
// @Success 302 {string} string "重定向到回调地址"
|
||||
@@ -57,21 +58,16 @@ func (h *SSOHandler) Authorize(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 验证 response_type
|
||||
if req.ResponseType != "code" && req.ResponseType != "token" {
|
||||
if req.ResponseType != "code" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "unsupported response_type"})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证 redirect_uri 是否在白名单中
|
||||
if h.clientsStore != nil {
|
||||
if !h.clientsStore.ValidateClientRedirectURI(req.ClientID, req.RedirectURI) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "invalid redirect_uri"})
|
||||
return
|
||||
}
|
||||
if h.clientsStore == nil || !h.clientsStore.ValidateClientRedirectURI(req.ClientID, req.RedirectURI) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "invalid redirect_uri"})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取当前登录用户(从 auth middleware 设置的 context)
|
||||
userID, ok := getUserIDFromContext(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "unauthorized"})
|
||||
@@ -84,60 +80,23 @@ func (h *SSOHandler) Authorize(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 生成授权码或 access token
|
||||
if req.ResponseType == "code" {
|
||||
code, err := h.ssoManager.GenerateAuthorizationCode(
|
||||
req.ClientID,
|
||||
req.RedirectURI,
|
||||
req.Scope,
|
||||
userID,
|
||||
username,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to generate code"})
|
||||
return
|
||||
}
|
||||
|
||||
// 重定向回客户端
|
||||
redirectURL := req.RedirectURI + "?code=" + code
|
||||
if req.State != "" {
|
||||
redirectURL += "&state=" + req.State
|
||||
}
|
||||
c.Redirect(http.StatusFound, redirectURL)
|
||||
} else {
|
||||
// implicit 模式,直接返回 token
|
||||
code, err := h.ssoManager.GenerateAuthorizationCode(
|
||||
req.ClientID,
|
||||
req.RedirectURI,
|
||||
req.Scope,
|
||||
userID,
|
||||
username,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to generate code"})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证授权码获取 session
|
||||
session, err := h.ssoManager.ValidateAuthorizationCode(code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to validate code"})
|
||||
return
|
||||
}
|
||||
|
||||
token, _, err := h.ssoManager.GenerateAccessToken(req.ClientID, session)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to generate token"})
|
||||
return
|
||||
}
|
||||
|
||||
// 重定向回客户端,带 token
|
||||
redirectURL := req.RedirectURI + "#access_token=" + token + "&expires_in=7200"
|
||||
if req.State != "" {
|
||||
redirectURL += "&state=" + req.State
|
||||
}
|
||||
c.Redirect(http.StatusFound, redirectURL)
|
||||
code, err := h.ssoManager.GenerateAuthorizationCode(
|
||||
req.ClientID,
|
||||
req.RedirectURI,
|
||||
req.Scope,
|
||||
userID,
|
||||
username,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to generate code"})
|
||||
return
|
||||
}
|
||||
|
||||
redirectURL := req.RedirectURI + "?code=" + code
|
||||
if req.State != "" {
|
||||
redirectURL += "&state=" + req.State
|
||||
}
|
||||
c.Redirect(http.StatusFound, redirectURL)
|
||||
}
|
||||
|
||||
// TokenRequest Token 请求
|
||||
@@ -161,14 +120,14 @@ type TokenResponse struct {
|
||||
// @Summary 获取 Access Token
|
||||
// @Description 使用授权码获取 Access Token(授权码模式第二步)
|
||||
// @Tags SSO
|
||||
// @Accept json
|
||||
// @Accept x-www-form-urlencoded
|
||||
// @Produce json
|
||||
// @Param grant_type formData string true "授权类型" Enums(authorization_code)
|
||||
// @Param code formData string false "授权码"
|
||||
// @Param redirect_uri formData string false "回调地址"
|
||||
// @Param code formData string true "授权码"
|
||||
// @Param redirect_uri formData string true "回调地址"
|
||||
// @Param client_id formData string true "客户端ID"
|
||||
// @Param client_secret formData string true "客户端密钥"
|
||||
// @Success 200 {object} TokenResponse "访问令牌响应"
|
||||
// @Success 200 {object} Response{data=TokenResponse} "访问令牌响应"
|
||||
// @Failure 400 {object} Response "请求参数错误"
|
||||
// @Failure 401 {object} Response "客户端认证失败"
|
||||
// @Failure 500 {object} Response "服务器错误"
|
||||
@@ -180,45 +139,50 @@ func (h *SSOHandler) Token(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 验证 grant_type
|
||||
if req.GrantType != "authorization_code" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "unsupported grant_type"})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证客户端凭证
|
||||
if h.clientsStore != nil {
|
||||
client, err := h.clientsStore.GetByClientID(req.ClientID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "invalid client"})
|
||||
return
|
||||
}
|
||||
// 使用常量时间比较防止时序攻击
|
||||
if subtle.ConstantTimeCompare([]byte(req.ClientSecret), []byte(client.ClientSecret)) != 1 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "invalid client_secret"})
|
||||
return
|
||||
}
|
||||
if req.Code == "" || req.RedirectURI == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "code and redirect_uri are required"})
|
||||
return
|
||||
}
|
||||
|
||||
client, ok := h.authenticateClient(req.ClientID, req.ClientSecret)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "invalid client credentials"})
|
||||
return
|
||||
}
|
||||
if !h.clientsStore.ValidateClientRedirectURI(client.ClientID, req.RedirectURI) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "invalid redirect_uri"})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证授权码
|
||||
session, err := h.ssoManager.ValidateAuthorizationCode(req.Code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "invalid code"})
|
||||
return
|
||||
}
|
||||
if session.ClientID != req.ClientID || session.RedirectURI != req.RedirectURI {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "authorization code does not match client or redirect_uri"})
|
||||
return
|
||||
}
|
||||
|
||||
// 生成 access token
|
||||
token, expiresAt, err := h.ssoManager.GenerateAccessToken(req.ClientID, session)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to generate token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, TokenResponse{
|
||||
AccessToken: token,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int64(time.Until(expiresAt).Seconds()),
|
||||
Scope: session.Scope,
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": TokenResponse{
|
||||
AccessToken: token,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int64(time.Until(expiresAt).Seconds()),
|
||||
Scope: session.Scope,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -241,33 +205,46 @@ type IntrospectResponse struct {
|
||||
// @Summary 验证 Access Token
|
||||
// @Description 验证 Access Token 的有效性并返回相关信息
|
||||
// @Tags SSO
|
||||
// @Accept json
|
||||
// @Accept x-www-form-urlencoded
|
||||
// @Produce json
|
||||
// @Param token formData string true "Access Token"
|
||||
// @Param client_id formData string false "客户端ID"
|
||||
// @Success 200 {object} IntrospectResponse "Token信息"
|
||||
// @Param client_id formData string true "客户端ID"
|
||||
// @Param client_secret formData string true "客户端密钥"
|
||||
// @Success 200 {object} Response{data=IntrospectResponse} "Token信息"
|
||||
// @Failure 400 {object} Response "请求参数错误"
|
||||
// @Failure 500 {object} Response "服务器错误"
|
||||
// @Failure 401 {object} Response "客户端认证失败"
|
||||
// @Router /api/v1/sso/introspect [post]
|
||||
func (h *SSOHandler) Introspect(c *gin.Context) {
|
||||
var req IntrospectRequest
|
||||
var req struct {
|
||||
Token string `form:"token" binding:"required"`
|
||||
ClientID string `form:"client_id" binding:"required"`
|
||||
ClientSecret string `form:"client_secret" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
if _, ok := h.authenticateClient(req.ClientID, req.ClientSecret); !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "invalid client credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
info, err := h.ssoManager.IntrospectToken(req.Token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, IntrospectResponse{Active: false})
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "success", "data": IntrospectResponse{Active: false}})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, IntrospectResponse{
|
||||
Active: info.Active,
|
||||
UserID: info.UserID,
|
||||
Username: info.Username,
|
||||
ExpiresAt: info.ExpiresAt.Unix(),
|
||||
Scope: info.Scope,
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": IntrospectResponse{
|
||||
Active: info.Active,
|
||||
UserID: info.UserID,
|
||||
Username: info.Username,
|
||||
ExpiresAt: info.ExpiresAt.Unix(),
|
||||
Scope: info.Scope,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -280,22 +257,30 @@ type RevokeRequest struct {
|
||||
// @Summary 撤销 Access Token
|
||||
// @Description 撤销指定的 Access Token
|
||||
// @Tags SSO
|
||||
// @Accept json
|
||||
// @Accept x-www-form-urlencoded
|
||||
// @Produce json
|
||||
// @Param token formData string true "Access Token"
|
||||
// @Param client_id formData string true "客户端ID"
|
||||
// @Param client_secret formData string true "客户端密钥"
|
||||
// @Success 200 {object} Response "撤销成功"
|
||||
// @Failure 400 {object} Response "请求参数错误"
|
||||
// @Failure 500 {object} Response "服务器错误"
|
||||
// @Failure 401 {object} Response "客户端认证失败"
|
||||
// @Router /api/v1/sso/revoke [post]
|
||||
func (h *SSOHandler) Revoke(c *gin.Context) {
|
||||
var req RevokeRequest
|
||||
var req struct {
|
||||
Token string `form:"token" binding:"required"`
|
||||
ClientID string `form:"client_id" binding:"required"`
|
||||
ClientSecret string `form:"client_secret" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.ssoManager.RevokeToken(req.Token)
|
||||
|
||||
if _, ok := h.authenticateClient(req.ClientID, req.ClientSecret); !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "invalid client credentials"})
|
||||
return
|
||||
}
|
||||
_ = h.ssoManager.RevokeToken(req.Token)
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "token revoked"})
|
||||
}
|
||||
|
||||
@@ -307,24 +292,23 @@ type UserInfoResponse struct {
|
||||
|
||||
// UserInfo 获取当前用户信息
|
||||
// @Summary 获取 SSO 用户信息
|
||||
// @Description 获取当前通过 SSO 授权的用户信息
|
||||
// @Description 获取当前通过 SSO Access Token 授权的用户信息
|
||||
// @Tags SSO
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} Response{data=UserInfoResponse} "用户信息"
|
||||
// @Failure 401 {object} Response "未认证"
|
||||
// @Failure 500 {object} Response "服务器错误"
|
||||
// @Router /api/v1/sso/userinfo [get]
|
||||
func (h *SSOHandler) UserInfo(c *gin.Context) {
|
||||
userID, ok := getUserIDFromContext(c)
|
||||
if !ok {
|
||||
token := extractBearerToken(c)
|
||||
if token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
username, ok := getUsernameFromContext(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "unauthorized"})
|
||||
session, err := h.ssoManager.ValidateAccessToken(token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "invalid access token"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -332,8 +316,30 @@ func (h *SSOHandler) UserInfo(c *gin.Context) {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": UserInfoResponse{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
UserID: session.UserID,
|
||||
Username: session.Username,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SSOHandler) authenticateClient(clientID, clientSecret string) (*auth.SSOClient, bool) {
|
||||
if h.clientsStore == nil {
|
||||
return nil, false
|
||||
}
|
||||
client, err := h.clientsStore.GetByClientID(clientID)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(clientSecret), []byte(client.ClientSecret)) != 1 {
|
||||
return nil, false
|
||||
}
|
||||
return client, true
|
||||
}
|
||||
|
||||
func extractBearerToken(c *gin.Context) string {
|
||||
authorization := c.GetHeader("Authorization")
|
||||
if !strings.HasPrefix(authorization, "Bearer ") {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(strings.TrimPrefix(authorization, "Bearer "))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user