mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 13:31:56 +00:00
Feat(BE-69,70,71,72,73): crud and integration sso with lti, revoke_token
This commit is contained in:
@@ -25,22 +25,24 @@ import (
|
||||
type Controller struct {
|
||||
httpClient *http.Client
|
||||
store *session.Store
|
||||
revoker *session.RevocationStore
|
||||
}
|
||||
|
||||
func NewController(client *http.Client, store *session.Store) *Controller {
|
||||
return &Controller{httpClient: client, store: store}
|
||||
func NewController(client *http.Client, store *session.Store, revoker *session.RevocationStore) *Controller {
|
||||
return &Controller{httpClient: client, store: store, revoker: revoker}
|
||||
}
|
||||
|
||||
// Start handles GET /sso/start requests and redirects users to the central SSO authorize endpoint.
|
||||
func (h *Controller) Start(c *fiber.Ctx) error {
|
||||
alias := strings.ToLower(strings.TrimSpace(c.Query("client")))
|
||||
if alias == "" {
|
||||
alias = strings.ToLower(strings.TrimSpace(c.Query("client_id")))
|
||||
requestedAlias := normalizeClientParam(c.Query("client"))
|
||||
if requestedAlias == "" {
|
||||
requestedAlias = normalizeClientParam(c.Query("client_id"))
|
||||
}
|
||||
if alias == "" {
|
||||
if requestedAlias == "" {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "missing client")
|
||||
}
|
||||
cfg, ok := config.SSOClients[alias]
|
||||
|
||||
alias, cfg, ok := findSSOClientConfig(requestedAlias)
|
||||
if !ok || cfg.PublicID == "" {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "unknown client")
|
||||
}
|
||||
@@ -209,6 +211,7 @@ func (h *Controller) Callback(c *fiber.Ctx) error {
|
||||
return fiber.NewError(fiber.StatusBadGateway, "missing access token")
|
||||
}
|
||||
|
||||
fmt.Println(tokenResp.AccessToken)
|
||||
verification, err := sso.VerifyAccessToken(tokenResp.AccessToken)
|
||||
if err != nil {
|
||||
utils.Log.Errorf("access token verification failed: %v", err)
|
||||
@@ -223,7 +226,6 @@ func (h *Controller) Callback(c *fiber.Ctx) error {
|
||||
redirectTarget = "/"
|
||||
}
|
||||
|
||||
fmt.Println(sessionData.ClientAlias,"test")
|
||||
utils.Log.WithFields(logrus.Fields{
|
||||
"client": sessionData.ClientAlias,
|
||||
"user_id": verification.UserID,
|
||||
@@ -255,6 +257,24 @@ func (h *Controller) UserInfo(c *fiber.Ctx) error {
|
||||
return fiber.NewError(fiber.StatusUnauthorized, "unauthenticated")
|
||||
}
|
||||
|
||||
if revoker := session.GetRevocationStore(); revoker != nil {
|
||||
if fingerprint := session.TokenFingerprint(token); fingerprint != "" {
|
||||
revoked, err := revoker.IsRevoked(c.Context(), fingerprint)
|
||||
if err != nil {
|
||||
utils.Log.WithError(err).Warn("failed to check token revocation for userinfo")
|
||||
return fiber.NewError(fiber.StatusUnauthorized, "unauthenticated")
|
||||
}
|
||||
if revoked {
|
||||
return fiber.NewError(fiber.StatusUnauthorized, "unauthenticated")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := sso.VerifyAccessToken(token); err != nil {
|
||||
utils.Log.WithError(err).Warn("access token verification failed for userinfo")
|
||||
return fiber.NewError(fiber.StatusUnauthorized, "unauthenticated")
|
||||
}
|
||||
|
||||
endpoint := strings.TrimSpace(config.SSOGetMeURL)
|
||||
if endpoint == "" {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "userinfo endpoint not configured")
|
||||
@@ -297,6 +317,129 @@ func (h *Controller) UserInfo(c *fiber.Ctx) error {
|
||||
return c.Status(resp.StatusCode).Send(body)
|
||||
}
|
||||
|
||||
// Logout clears SSO cookies and removes any leftover PKCE session state.
|
||||
func (h *Controller) Logout(c *fiber.Ctx) error {
|
||||
requestedAlias := normalizeClientParam(c.Query("client"))
|
||||
if requestedAlias == "" {
|
||||
requestedAlias = normalizeClientParam(c.Query("client_id"))
|
||||
}
|
||||
|
||||
var (
|
||||
alias string
|
||||
cfg config.SSOClientConfig
|
||||
hasClientInfo bool
|
||||
)
|
||||
if requestedAlias != "" {
|
||||
alias, cfg, hasClientInfo = findSSOClientConfig(requestedAlias)
|
||||
}
|
||||
|
||||
accessName := resolveSSOCookieName(config.SSOAccessCookieName, "access")
|
||||
refreshName := resolveSSOCookieName(config.SSORefreshCookieName, "refresh")
|
||||
|
||||
var accessToken, refreshToken string
|
||||
if accessName != "" {
|
||||
accessToken = strings.TrimSpace(c.Cookies(accessName))
|
||||
}
|
||||
if refreshName != "" {
|
||||
refreshToken = strings.TrimSpace(c.Cookies(refreshName))
|
||||
}
|
||||
|
||||
hadAccessCookie := accessToken != ""
|
||||
hadRefreshCookie := refreshToken != ""
|
||||
|
||||
state := strings.TrimSpace(c.Query("state"))
|
||||
if state != "" {
|
||||
if err := h.store.Delete(c.Context(), state); err != nil {
|
||||
utils.Log.Warnf("failed to delete pkce session during logout: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !hadAccessCookie && !hadRefreshCookie && state == "" {
|
||||
return fiber.NewError(fiber.StatusUnauthorized, "not authenticated")
|
||||
}
|
||||
|
||||
if hadAccessCookie {
|
||||
if verification, err := sso.VerifyAccessToken(accessToken); err != nil {
|
||||
utils.Log.WithError(err).Warn("failed to verify access token during logout")
|
||||
} else {
|
||||
h.revokeToken(c.Context(), accessToken, verification)
|
||||
}
|
||||
}
|
||||
if refreshToken != "" {
|
||||
h.revokeRefreshToken(c.Context(), refreshToken)
|
||||
}
|
||||
|
||||
clearSSOCookie(c, accessName)
|
||||
clearSSOCookie(c, refreshName)
|
||||
|
||||
redirectTarget := ""
|
||||
rawReturn := strings.TrimSpace(c.Query("return_to"))
|
||||
if hasClientInfo {
|
||||
if rawReturn == "" {
|
||||
rawReturn = cfg.DefaultReturnURI
|
||||
}
|
||||
if normalized, err := normalizeReturnTarget(rawReturn, cfg); err == nil {
|
||||
redirectTarget = normalized
|
||||
} else if rawReturn != "" {
|
||||
utils.Log.WithError(err).Warn("invalid return_to during logout")
|
||||
}
|
||||
} else if rawReturn != "" {
|
||||
if strings.HasPrefix(rawReturn, "/") && !strings.HasPrefix(rawReturn, "//") {
|
||||
redirectTarget = rawReturn
|
||||
}
|
||||
}
|
||||
|
||||
utils.Log.WithFields(logrus.Fields{
|
||||
"client": alias,
|
||||
"state": state,
|
||||
"redirect": redirectTarget,
|
||||
}).Info("sso logout completed")
|
||||
|
||||
if redirectTarget != "" {
|
||||
return c.Redirect(redirectTarget, fiber.StatusFound)
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "signed out"})
|
||||
}
|
||||
|
||||
func (h *Controller) revokeToken(ctx context.Context, token string, verification *sso.VerificationResult) {
|
||||
if h.revoker == nil || verification == nil || verification.Claims == nil {
|
||||
return
|
||||
}
|
||||
fingerprint := session.TokenFingerprint(token)
|
||||
if fingerprint == "" {
|
||||
return
|
||||
}
|
||||
if verification.Claims.ExpiresAt == nil {
|
||||
utils.Log.Warn("access token missing expiry claim")
|
||||
return
|
||||
}
|
||||
ttl := time.Until(verification.Claims.ExpiresAt.Time)
|
||||
if ttl <= 0 {
|
||||
return
|
||||
}
|
||||
if ttl < time.Second {
|
||||
ttl = time.Second
|
||||
}
|
||||
if err := h.revoker.Revoke(ctx, fingerprint, ttl); err != nil {
|
||||
utils.Log.WithError(err).Warn("failed to revoke access token")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Controller) revokeRefreshToken(ctx context.Context, token string) {
|
||||
if h.revoker == nil {
|
||||
return
|
||||
}
|
||||
fingerprint := session.TokenFingerprint(token)
|
||||
if fingerprint == "" {
|
||||
return
|
||||
}
|
||||
const refreshTTL = 30 * 24 * time.Hour
|
||||
if err := h.revoker.Revoke(ctx, fingerprint, refreshTTL); err != nil {
|
||||
utils.Log.WithError(err).Warn("failed to revoke refresh token")
|
||||
}
|
||||
}
|
||||
|
||||
func issueCookies(c *fiber.Ctx, tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
@@ -307,15 +450,8 @@ func issueCookies(c *fiber.Ctx, tokenResp struct {
|
||||
Error string `json:"error"`
|
||||
Description string `json:"error_description"`
|
||||
}, verification *sso.VerificationResult) {
|
||||
fmt.Println(tokenResp.AccessToken)
|
||||
accessName := config.SSOAccessCookieName
|
||||
if accessName == "" {
|
||||
accessName = "access"
|
||||
}
|
||||
refreshName := config.SSORefreshCookieName
|
||||
if refreshName == "" {
|
||||
refreshName = "refresh"
|
||||
}
|
||||
accessName := resolveSSOCookieName(config.SSOAccessCookieName, "access")
|
||||
refreshName := resolveSSOCookieName(config.SSORefreshCookieName, "refresh")
|
||||
maxAge := tokenResp.ExpiresIn
|
||||
if maxAge <= 0 {
|
||||
maxAge = int(15 * time.Minute.Seconds())
|
||||
@@ -357,6 +493,64 @@ func issueCookies(c *fiber.Ctx, tokenResp struct {
|
||||
c.Set("X-Auth-User", fmt.Sprintf("%d", verification.UserID))
|
||||
}
|
||||
|
||||
func clearSSOCookie(c *fiber.Ctx, name string) {
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
|
||||
sameSite := config.SSOCookieSameSite
|
||||
if sameSite == "" {
|
||||
sameSite = "Lax"
|
||||
}
|
||||
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: name,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Domain: config.SSOCookieDomain,
|
||||
HTTPOnly: true,
|
||||
Secure: config.SSOCookieSecure,
|
||||
SameSite: sameSite,
|
||||
Expires: time.Unix(0, 0),
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
func resolveSSOCookieName(configuredName, fallback string) string {
|
||||
name := strings.TrimSpace(configuredName)
|
||||
if name != "" {
|
||||
return name
|
||||
}
|
||||
return strings.TrimSpace(fallback)
|
||||
}
|
||||
|
||||
func normalizeClientParam(raw string) string {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if idx := strings.Index(value, "|"); idx >= 0 {
|
||||
value = value[:idx]
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
return strings.ToLower(value)
|
||||
}
|
||||
|
||||
func findSSOClientConfig(requestedAlias string) (string, config.SSOClientConfig, bool) {
|
||||
if requestedAlias == "" {
|
||||
return "", config.SSOClientConfig{}, false
|
||||
}
|
||||
if cfg, ok := config.SSOClients[requestedAlias]; ok && strings.TrimSpace(cfg.PublicID) != "" {
|
||||
return requestedAlias, cfg, true
|
||||
}
|
||||
for alias, cfg := range config.SSOClients {
|
||||
if strings.EqualFold(strings.TrimSpace(cfg.PublicID), requestedAlias) && strings.TrimSpace(cfg.PublicID) != "" {
|
||||
return alias, cfg, true
|
||||
}
|
||||
}
|
||||
return "", config.SSOClientConfig{}, false
|
||||
}
|
||||
|
||||
func normalizeReturnTarget(returnTo string, cfg config.SSOClientConfig) (string, error) {
|
||||
returnTo = strings.TrimSpace(returnTo)
|
||||
if returnTo == "" {
|
||||
|
||||
@@ -13,22 +13,21 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
|
||||
userRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/response"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/sso"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
)
|
||||
|
||||
|
||||
const (
|
||||
headerClient = "X-Sync-Client"
|
||||
headerTimestamp = "X-Sync-Timestamp"
|
||||
@@ -209,6 +208,18 @@ func (h *UserSyncController) authenticate(c *fiber.Ctx, body []byte) (string, co
|
||||
|
||||
expectedSignature := h.calculateSignature(secret, rawAlias, timestamp, nonce, body)
|
||||
if !hmac.Equal(providedSig, expectedSignature) {
|
||||
bodyHash := sha256.Sum256(body)
|
||||
h.log.WithFields(logrus.Fields{
|
||||
"alias": rawAlias,
|
||||
"alias_key": aliasKey,
|
||||
"timestamp": timestamp,
|
||||
"nonce": nonce,
|
||||
"body_len": len(body),
|
||||
"body_sha256": hex.EncodeToString(bodyHash[:]),
|
||||
"body_base64": base64.StdEncoding.EncodeToString(body),
|
||||
"provided_hex_full": hex.EncodeToString(providedSig),
|
||||
"expected_hex_full": hex.EncodeToString(expectedSignature),
|
||||
}).Warn("sso sync signature mismatch")
|
||||
return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "invalid signature")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user