diff --git a/pkg/modules/auth/auth.go b/pkg/modules/auth/auth.go index 2b6662031..402de54e9 100644 --- a/pkg/modules/auth/auth.go +++ b/pkg/modules/auth/auth.go @@ -192,6 +192,62 @@ func GetAuthFromClaims(c *echo.Context) (a web.Auth, err error) { return nil, echo.NewHTTPError(http.StatusBadRequest, "Invalid JWT token.") } +// ValidateAPITokenString looks up an API token by its raw string, checks expiry, +// and returns the token and its owner. This is the shared validation logic used +// by both the HTTP middleware and WebSocket auth. +func ValidateAPITokenString(tokenString string) (*models.APIToken, *user.User, error) { + s := db.NewSession() + defer s.Close() + + token, err := models.GetTokenFromTokenString(s, tokenString) + if err != nil { + return nil, nil, err + } + + if time.Now().After(token.ExpiresAt) { + return nil, nil, fmt.Errorf("API token %d expired on %s", token.ID, token.ExpiresAt.String()) + } + + u, err := user.GetUserByID(s, token.OwnerID) + if err != nil { + if user.IsErrUserStatusError(err) { + return nil, nil, fmt.Errorf("API token %d owner account is disabled or locked", token.ID) + } + return nil, nil, err + } + + return token, u, nil +} + +// GetUserIDFromToken parses a raw JWT token string and returns the user ID. +// Only regular user tokens are accepted (not link shares). +// Returns 0 and an error if the token is invalid. +func GetUserIDFromToken(tokenString string) (int64, error) { + token, err := jwt.Parse(tokenString, func(_ *jwt.Token) (any, error) { + return []byte(config.ServiceSecret.GetString()), nil + }) + if err != nil { + return 0, err + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok || !token.Valid { + return 0, jwt.ErrTokenInvalidClaims + } + + typ, ok := claims["type"].(float64) + if !ok || int(typ) != AuthTypeUser { + return 0, jwt.ErrTokenInvalidClaims + } + + userIDFloat, ok := claims["id"].(float64) + if !ok { + return 0, jwt.ErrTokenInvalidClaims + } + + return int64(userIDFloat), nil +} + func CreateUserWithRandomUsername(s *xorm.Session, uu *user.User) (u *user.User, err error) { // Check if we actually have a preferred username and generate a random one right away if we don't for { diff --git a/pkg/routes/api_tokens.go b/pkg/routes/api_tokens.go index cc6c2f546..40f48fec7 100644 --- a/pkg/routes/api_tokens.go +++ b/pkg/routes/api_tokens.go @@ -21,9 +21,9 @@ import ( "strings" "code.vikunja.io/api/pkg/config" - "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/auth" "code.vikunja.io/api/pkg/web" echojwt "github.com/labstack/echo-jwt/v5" @@ -72,14 +72,9 @@ func SetupTokenMiddleware() echo.MiddlewareFunc { } func checkAPITokenAndPutItInContext(tokenHeaderValue string, c *echo.Context) error { - s := db.NewSession() - defer s.Close() - - token, u, err := models.ValidateTokenAndGetOwner(s, strings.TrimPrefix(tokenHeaderValue, "Bearer ")) + token, u, err := auth.ValidateAPITokenString(strings.TrimPrefix(tokenHeaderValue, "Bearer ")) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Internal Server Error").Wrap(err) - } - if token == nil || u == nil { + log.Debugf("[auth] API token validation failed: %v", err) return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") }