feat: add session-based auth with refresh token rotation

- Login creates a server-side session and sets an HttpOnly refresh
  token cookie alongside the short-lived JWT
- POST /user/token/refresh exchanges the cookie for a new JWT and
  rotates the refresh token atomically
- POST /user/logout destroys the session and clears the cookie
- POST /user/token restricted to link share tokens only
- Session list (GET) and delete (DELETE) routes for /user/sessions
- All user sessions invalidated on password change and reset
- CORS configured to allow credentials for cross-origin cookies
- JWT 401 responses use structured error code 11 for client detection
- Refresh token cookie name constants annotated for gosec G101
This commit is contained in:
kolaente
2026-02-25 09:33:51 +01:00
parent b3d0b2f697
commit 8ee069a2a3
12 changed files with 292 additions and 65 deletions

View File

@@ -19,6 +19,7 @@ package auth
import (
"fmt"
"net/http"
"strings"
"time"
"code.vikunja.io/api/pkg/config"
@@ -29,6 +30,7 @@ import (
petname "github.com/dustinkirkland/golang-petname"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/labstack/echo/v5"
"xorm.io/xorm"
)
@@ -45,35 +47,85 @@ type Token struct {
Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"`
}
const RefreshTokenCookieName = "vikunja_refresh_token" //nolint:gosec // not a credential
const refreshTokenCookiePath = "/api/v1/user/token/refresh" //nolint:gosec // not a credential
// SetRefreshTokenCookie sets an HttpOnly cookie containing the refresh token.
// The cookie is path-scoped to the refresh endpoint so the browser only sends
// it on refresh requests. HttpOnly prevents JavaScript access (XSS protection).
func SetRefreshTokenCookie(c *echo.Context, token string, maxAge int) {
secure := strings.HasPrefix(config.ServicePublicURL.GetString(), "https")
c.SetCookie(&http.Cookie{
Name: RefreshTokenCookieName,
Value: token,
Path: refreshTokenCookiePath,
MaxAge: maxAge,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
})
}
// ClearRefreshTokenCookie removes the refresh token cookie.
func ClearRefreshTokenCookie(c *echo.Context) {
SetRefreshTokenCookie(c, "", -1)
}
// NewUserAuthTokenResponse creates a new user auth token response from a user object.
func NewUserAuthTokenResponse(u *user.User, c *echo.Context, long bool) error {
t, err := NewUserJWTAuthtoken(u, long)
s := db.NewSession()
defer s.Close()
deviceInfo := c.Request().UserAgent()
ipAddress := c.RealIP()
session, err := models.CreateSession(s, u.ID, deviceInfo, ipAddress, long)
if err != nil {
_ = s.Rollback()
return err
}
t, err := NewUserJWTAuthtoken(u, session.ID)
if err != nil {
_ = s.Rollback()
return err
}
if err := s.Commit(); err != nil {
_ = s.Rollback()
return err
}
// Set the refresh token as an HttpOnly cookie. The cookie is path-scoped
// to the refresh endpoint, so the browser only sends it there. JavaScript
// never sees the refresh token — this protects it from XSS.
cookieMaxAge := int(config.ServiceJWTTTL.GetInt64())
if long {
cookieMaxAge = int(config.ServiceJWTTTLLong.GetInt64())
}
SetRefreshTokenCookie(c, session.RefreshToken, cookieMaxAge)
c.Response().Header().Set("Cache-Control", "no-store")
return c.JSON(http.StatusOK, Token{Token: t})
}
// NewUserJWTAuthtoken generates and signs a new jwt token for a user. This is a global function to be able to call it from web tests.
func NewUserJWTAuthtoken(u *user.User, long bool) (token string, err error) {
// NewUserJWTAuthtoken generates and signs a new short-lived jwt token for a user.
// The token includes the session UUID as the `sid` claim. This is a global
// function to be able to call it from web tests.
func NewUserJWTAuthtoken(u *user.User, sessionID string) (token string, err error) {
t := jwt.New(jwt.SigningMethodHS256)
var ttl = time.Duration(config.ServiceJWTTTL.GetInt64())
if long {
ttl = time.Duration(config.ServiceJWTTTLLong.GetInt64())
}
var ttl = time.Duration(config.ServiceJWTTTLShort.GetInt64())
var exp = time.Now().Add(time.Second * ttl).Unix()
// Set claims
claims := t.Claims.(jwt.MapClaims)
claims["type"] = AuthTypeUser
claims["id"] = u.ID
claims["username"] = u.Username
claims["exp"] = exp
claims["long"] = long
claims["sid"] = sessionID
claims["jti"] = uuid.New().String()
// Generate encoded token and send it as response.
return t.SignedString([]byte(config.ServiceJWTSecret.GetString()))
}

View File

@@ -18,6 +18,7 @@ package v1
import (
"net/http"
"time"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
@@ -116,47 +117,43 @@ func Login(c *echo.Context) (err error) {
return auth.NewUserAuthTokenResponse(user, c, u.LongToken)
}
// RenewToken gives a new token to every user with a valid token
// If the token is valid is checked in the middleware.
// @Summary Renew user token
// @Description Returns a new valid jwt user token with an extended length.
// @tags user
// RenewToken renews a link share token only. User tokens must use
// POST /user/token/refresh with a refresh token instead.
// @Summary Renew link share token
// @Description Returns a new valid jwt link share token. Only works for link share tokens.
// @tags auth
// @Accept json
// @Produce json
// @Success 200 {object} auth.Token
// @Failure 400 {object} models.Message "Only user token are available for renew."
// @Failure 400 {object} models.Message "Only link share tokens can be renewed."
// @Router /user/token [post]
func RenewToken(c *echo.Context) (err error) {
jwtinf := c.Get("user").(*jwt.Token)
claims := jwtinf.Claims.(jwt.MapClaims)
typ := int(claims["type"].(float64))
if typ == auth.AuthTypeUser {
return echo.NewHTTPError(
http.StatusBadRequest,
"User tokens cannot be renewed via this endpoint. Use POST /user/token/refresh with a refresh token.",
)
}
if typ != auth.AuthTypeLinkShare {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid token type.")
}
s := db.NewSession()
defer s.Close()
jwtinf := c.Get("user").(*jwt.Token)
claims := jwtinf.Claims.(jwt.MapClaims)
typ := int(claims["type"].(float64))
if typ == auth.AuthTypeLinkShare {
share := &models.LinkSharing{}
share.ID = int64(claims["id"].(float64))
err := share.ReadOne(s, share)
if err != nil {
_ = s.Rollback()
return err
}
t, err := auth.NewLinkShareJWTAuthtoken(share)
if err != nil {
_ = s.Rollback()
return err
}
return c.JSON(http.StatusOK, auth.Token{Token: t})
}
u, err := user2.GetUserFromClaims(claims)
share := &models.LinkSharing{}
share.ID = int64(claims["id"].(float64))
err = share.ReadOne(s, share)
if err != nil {
_ = s.Rollback()
return err
}
user, err := user2.GetUserWithEmail(s, &user2.User{ID: u.ID})
t, err := auth.NewLinkShareJWTAuthtoken(share)
if err != nil {
_ = s.Rollback()
return err
@@ -167,12 +164,146 @@ func RenewToken(c *echo.Context) (err error) {
return err
}
var long bool
lng, has := claims["long"]
if has {
long = lng.(bool)
return c.JSON(http.StatusOK, auth.Token{Token: t})
}
// RefreshToken exchanges a valid refresh token (sent as an HttpOnly cookie) for
// a new short-lived JWT. The refresh token is rotated on every call.
// @Summary Refresh user token
// @Description Exchanges the refresh token cookie for a new short-lived JWT.
// @tags auth
// @Produce json
// @Success 200 {object} auth.Token
// @Failure 401 {object} models.Message "Invalid or expired refresh token."
// @Router /user/token/refresh [post]
func RefreshToken(c *echo.Context) (err error) {
cookie, err := c.Cookie(auth.RefreshTokenCookieName)
if err != nil || cookie.Value == "" {
return echo.NewHTTPError(http.StatusUnauthorized, "No refresh token provided.")
}
rawToken := cookie.Value
s := db.NewSession()
defer s.Close()
session, err := models.GetSessionByRefreshToken(s, rawToken)
if err != nil {
_ = s.Rollback()
if models.IsErrSessionNotFound(err) {
// Don't clear the cookie here — another tab may have already
// rotated the token, and clearing would overwrite the new cookie.
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid or expired refresh token.")
}
return err
}
// Create token
return auth.NewUserAuthTokenResponse(user, c, long)
// Check if the session has expired based on its type
maxAge := time.Duration(config.ServiceJWTTTL.GetInt64()) * time.Second
if session.IsLongSession {
maxAge = time.Duration(config.ServiceJWTTTLLong.GetInt64()) * time.Second
}
if time.Since(session.LastActive) > maxAge {
if _, err := s.Where("id = ?", session.ID).Delete(&models.Session{}); err != nil {
_ = s.Rollback()
return err
}
if err := s.Commit(); err != nil {
return err
}
auth.ClearRefreshTokenCookie(c)
return echo.NewHTTPError(http.StatusUnauthorized, "Session expired.")
}
if err := models.UpdateSessionLastActive(s, session.ID); err != nil {
_ = s.Rollback()
return err
}
newRawToken, err := models.RotateRefreshToken(s, session)
if err != nil {
_ = s.Rollback()
if models.IsErrSessionNotFound(err) {
// Don't clear the cookie — a concurrent request in another tab
// may have already rotated the token successfully.
return echo.NewHTTPError(http.StatusUnauthorized, "Refresh token already used.")
}
return err
}
u, err := user2.GetUserWithEmail(s, &user2.User{ID: session.UserID})
if err != nil {
_ = s.Rollback()
return err
}
if u.Status == user2.StatusDisabled {
if _, err := s.Where("id = ?", session.ID).Delete(&models.Session{}); err != nil {
_ = s.Rollback()
return err
}
if err := s.Commit(); err != nil {
return err
}
auth.ClearRefreshTokenCookie(c)
return &user2.ErrAccountDisabled{UserID: u.ID}
}
if err := s.Commit(); err != nil {
_ = s.Rollback()
return err
}
t, err := auth.NewUserJWTAuthtoken(u, session.ID)
if err != nil {
return err
}
cookieMaxAge := int(config.ServiceJWTTTL.GetInt64())
if session.IsLongSession {
cookieMaxAge = int(config.ServiceJWTTTLLong.GetInt64())
}
auth.SetRefreshTokenCookie(c, newRawToken, cookieMaxAge)
c.Response().Header().Set("Cache-Control", "no-store")
return c.JSON(http.StatusOK, auth.Token{Token: t})
}
// Logout deletes the current session from the server.
// @Summary Logout
// @Description Destroys the current session and clears the refresh token cookie.
// @tags auth
// @Produce json
// @Success 200 {object} models.Message "Successfully logged out."
// @Router /user/logout [post]
func Logout(c *echo.Context) (err error) {
auth.ClearRefreshTokenCookie(c)
var sid string
if raw := c.Get("user"); raw != nil {
if jwtinf, ok := raw.(*jwt.Token); ok {
if claims, ok := jwtinf.Claims.(jwt.MapClaims); ok {
sid, _ = claims["sid"].(string)
}
}
}
if sid == "" {
return c.JSON(http.StatusOK, models.Message{Message: "Successfully logged out."})
}
s := db.NewSession()
defer s.Close()
_, err = s.Where("id = ?", sid).Delete(&models.Session{})
if err != nil {
_ = s.Rollback()
return err
}
if err := s.Commit(); err != nil {
_ = s.Rollback()
return err
}
return c.JSON(http.StatusOK, models.Message{Message: "Successfully logged out."})
}

View File

@@ -47,12 +47,17 @@ func UserResetPassword(c *echo.Context) error {
s := db.NewSession()
defer s.Close()
err := user.ResetPassword(s, &pwReset)
userID, err := user.ResetPassword(s, &pwReset)
if err != nil {
_ = s.Rollback()
return err
}
if err := models.DeleteAllUserSessions(s, userID); err != nil {
_ = s.Rollback()
return err
}
if err := s.Commit(); err != nil {
_ = s.Rollback()
return err

View File

@@ -77,6 +77,11 @@ func UserChangePassword(c *echo.Context) error {
return err
}
if err := models.DeleteAllUserSessions(s, doer.ID); err != nil {
_ = s.Rollback()
return err
}
if err := s.Commit(); err != nil {
_ = s.Rollback()
return err

View File

@@ -26,11 +26,17 @@ import (
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/web"
echojwt "github.com/labstack/echo-jwt/v5"
"github.com/labstack/echo/v5"
)
// ErrCodeInvalidToken is the error code returned when the JWT is missing,
// malformed, or expired. The frontend uses this to distinguish "token expired,
// try refreshing" from other 401s (disabled account, wrong API token, etc.).
const ErrCodeInvalidToken = 11
func SetupTokenMiddleware() echo.MiddlewareFunc {
return echojwt.WithConfig(echojwt.Config{
SigningKey: []byte(config.ServiceJWTSecret.GetString()),
@@ -53,9 +59,13 @@ func SetupTokenMiddleware() echo.MiddlewareFunc {
return false
},
ErrorHandler: func(_ *echo.Context, err error) error {
ErrorHandler: func(c *echo.Context, err error) error {
if err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "missing, malformed, expired or otherwise invalid token provided")
return c.JSON(http.StatusUnauthorized, web.HTTPError{
HTTPCode: http.StatusUnauthorized,
Code: ErrCodeInvalidToken,
Message: "missing, malformed, expired or otherwise invalid token provided",
})
}
return nil

View File

@@ -227,7 +227,8 @@ func RegisterRoutes(e *echo.Echo) {
UnsafeAllowOriginFunc: func(_ *echo.Context, origin string) (string, bool, error) {
return matchCORSOrigin(origin, allowedOrigins)
},
MaxAge: config.CorsMaxAge.GetInt(),
AllowCredentials: true,
MaxAge: config.CorsMaxAge.GetInt(),
Skipper: func(context *echo.Context) bool {
// Since it is not possible to register this middleware just for the api group,
// we just disable it when for caldav requests.
@@ -254,6 +255,7 @@ var unauthenticatedAPIPaths = map[string]bool{
"/api/v1/user/password/reset": true,
"/api/v1/user/confirm": true,
"/api/v1/login": true,
"/api/v1/user/token/refresh": true,
"/api/v1/auth/openid/:provider/callback": true,
"/api/v1/test/:table": true,
"/api/v1/info": true,
@@ -326,6 +328,10 @@ func registerAPIRoutes(a *echo.Group) {
ur.POST("/login", apiv1.Login)
}
// Refresh token endpoint — unauthenticated because it uses the refresh
// token cookie instead of a JWT bearer token.
ur.POST("/user/token/refresh", apiv1.RefreshToken)
if config.AuthOpenIDEnabled.GetBool() {
ur.POST("/auth/openid/:provider/callback", openid.HandleCallback)
}
@@ -366,6 +372,7 @@ func registerAPIRoutes(a *echo.Group) {
u.POST("/password", apiv1.UserChangePassword)
u.GET("s", apiv1.UserList)
u.POST("/token", apiv1.RenewToken)
u.POST("/logout", apiv1.Logout)
u.POST("/settings/email", apiv1.UpdateUserEmail)
u.GET("/settings/avatar", apiv1.GetUserAvatarProvider)
u.POST("/settings/avatar", apiv1.ChangeUserAvatarProvider)
@@ -379,6 +386,14 @@ func registerAPIRoutes(a *echo.Group) {
u.GET("/settings/token/caldav", apiv1.GetCaldavTokens)
u.DELETE("/settings/token/caldav/:id", apiv1.DeleteCaldavToken)
sessionProvider := &handler.WebHandler{
EmptyStruct: func() handler.CObject {
return &models.Session{}
},
}
u.GET("/sessions", sessionProvider.ReadAllWeb)
u.DELETE("/sessions/:session", sessionProvider.DeleteWeb)
if config.ServiceEnableTotp.GetBool() {
u.GET("/settings/totp", apiv1.UserTOTP)
u.POST("/settings/totp/enroll", apiv1.UserTOTPEnroll)

View File

@@ -30,31 +30,34 @@ type PasswordReset struct {
NewPassword string `json:"new_password"`
}
// ResetPassword resets a users password
func ResetPassword(s *xorm.Session, reset *PasswordReset) (err error) {
// ResetPassword resets a users password. It returns the ID of the user whose
// password was reset so callers can perform additional cleanup (e.g. session
// invalidation).
func ResetPassword(s *xorm.Session, reset *PasswordReset) (userID int64, err error) {
// Check if the password is not empty
if reset.NewPassword == "" {
return ErrNoUsernamePassword{}
return 0, ErrNoUsernamePassword{}
}
if reset.Token == "" {
return ErrNoPasswordResetToken{}
return 0, ErrNoPasswordResetToken{}
}
// Check if we have a token
token, err := getToken(s, reset.Token, TokenPasswordReset)
if err != nil {
return err
return 0, err
}
if token == nil {
return ErrInvalidPasswordResetToken{Token: reset.Token}
return 0, ErrInvalidPasswordResetToken{Token: reset.Token}
}
user, err := GetUserByID(s, token.UserID)
if err != nil {
return
}
userID = user.ID
// Hash the password
user.Password, err = HashPassword(reset.NewPassword)

View File

@@ -555,7 +555,7 @@ func TestUserPasswordReset(t *testing.T) {
Token: "passwordresettesttoken",
NewPassword: "12345",
}
err := ResetPassword(s, reset)
_, err := ResetPassword(s, reset)
require.NoError(t, err)
})
t.Run("without password", func(t *testing.T) {
@@ -566,7 +566,7 @@ func TestUserPasswordReset(t *testing.T) {
reset := &PasswordReset{
Token: "passwordresettesttoken",
}
err := ResetPassword(s, reset)
_, err := ResetPassword(s, reset)
require.Error(t, err)
assert.True(t, IsErrNoUsernamePassword(err))
})
@@ -579,7 +579,7 @@ func TestUserPasswordReset(t *testing.T) {
Token: "",
NewPassword: "12345",
}
err := ResetPassword(s, reset)
_, err := ResetPassword(s, reset)
require.Error(t, err)
assert.True(t, IsErrNoPasswordResetToken(err))
})
@@ -592,7 +592,7 @@ func TestUserPasswordReset(t *testing.T) {
Token: "somethingsomething",
NewPassword: "12345",
}
err := ResetPassword(s, reset)
_, err := ResetPassword(s, reset)
require.Error(t, err)
assert.True(t, IsErrInvalidPasswordResetToken(err))
})

View File

@@ -63,7 +63,9 @@ func TestAPIToken(t *testing.T) {
})
req.Header.Set(echo.HeaderAuthorization, "Bearer tk_loremipsumdolorsitamet")
require.Error(t, h(c))
require.NoError(t, h(c))
assert.Equal(t, http.StatusUnauthorized, res.Code)
assert.Contains(t, res.Body.String(), `"code":11`)
})
t.Run("expired token", func(t *testing.T) {
e, err := setupTestEnv()
@@ -76,7 +78,9 @@ func TestAPIToken(t *testing.T) {
})
req.Header.Set(echo.HeaderAuthorization, "Bearer tk_a5e6f92ddbad68f49ee2c63e52174db0235008c8") // Token 2
require.Error(t, h(c))
require.NoError(t, h(c))
assert.Equal(t, http.StatusUnauthorized, res.Code)
assert.Contains(t, res.Body.String(), `"code":11`)
})
t.Run("valid token, invalid scope", func(t *testing.T) {
e, err := setupTestEnv()
@@ -89,7 +93,9 @@ func TestAPIToken(t *testing.T) {
})
req.Header.Set(echo.HeaderAuthorization, "Bearer tk_2eef46f40ebab3304919ab2e7e39993f75f29d2e")
require.Error(t, h(c))
require.NoError(t, h(c))
assert.Equal(t, http.StatusUnauthorized, res.Code)
assert.Contains(t, res.Body.String(), `"code":11`)
})
t.Run("jwt", func(t *testing.T) {
e, err := setupTestEnv()
@@ -105,7 +111,7 @@ func TestAPIToken(t *testing.T) {
defer s.Close()
u, err := user.GetUserByID(s, 1)
require.NoError(t, err)
jwt, err := auth.NewUserJWTAuthtoken(u, false)
jwt, err := auth.NewUserJWTAuthtoken(u, "test-session-id")
require.NoError(t, err)
req.Header.Set(echo.HeaderAuthorization, "Bearer "+jwt)

View File

@@ -49,7 +49,7 @@ func TestErrorResponseFormats(t *testing.T) {
require.NoError(t, err)
// Get auth token for testuser1
token, err := auth.NewUserJWTAuthtoken(&testuser1, false)
token, err := auth.NewUserJWTAuthtoken(&testuser1, "test-session-id")
require.NoError(t, err)
t.Run("validation error returns invalid_fields in JSON body", func(t *testing.T) {

View File

@@ -123,7 +123,7 @@ func newTestRequest(t *testing.T, method string, handler func(ctx *echo.Context)
func addUserTokenToContext(t *testing.T, user *user.User, c *echo.Context) {
// Get the token as a string
token, err := auth.NewUserJWTAuthtoken(user, false)
token, err := auth.NewUserJWTAuthtoken(user, "test-session-id")
require.NoError(t, err)
// We send the string token through the parsing function to get a valid jwt.Token
tken, err := jwt.Parse(token, func(_ *jwt.Token) (interface{}, error) {

View File

@@ -96,7 +96,7 @@ func TestTaskAttachmentUploadSize(t *testing.T) {
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
// Add JWT token to request header for authentication
token, err := auth.NewUserJWTAuthtoken(&testuser1, false)
token, err := auth.NewUserJWTAuthtoken(&testuser1, "test-session-id")
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+token)