diff --git a/pkg/modules/auth/auth.go b/pkg/modules/auth/auth.go index 476416695..dc2ba2c78 100644 --- a/pkg/modules/auth/auth.go +++ b/pkg/modules/auth/auth.go @@ -216,3 +216,95 @@ func CreateUserWithRandomUsername(s *xorm.Session, uu *user.User) (u *user.User, err = models.CreateNewProjectForUser(s, u) return } + +// RefreshResult holds the result of a successful session refresh. +type RefreshResult struct { + AccessToken string + NewRefreshToken string + ExpiresIn int64 + IsLongSession bool + SessionID string +} + +// RefreshSession looks up a session by its raw refresh token, validates it, +// rotates the refresh token, fetches the user, and generates a new JWT. +// It handles its own DB session (open/commit/rollback). +// +// On user status errors (disabled/locked), the session is deleted before +// returning the error so the caller can handle cleanup (e.g. clearing cookies). +func RefreshSession(rawRefreshToken string) (*RefreshResult, error) { + s := db.NewSession() + defer s.Close() + + session, err := models.GetSessionByRefreshToken(s, rawRefreshToken) + if err != nil { + _ = s.Rollback() + if models.IsErrSessionNotFound(err) { + return nil, echo.NewHTTPError(http.StatusUnauthorized, "Invalid or expired refresh token.") + } + return nil, err + } + + 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 nil, err + } + if err := s.Commit(); err != nil { + return nil, err + } + return nil, echo.NewHTTPError(http.StatusUnauthorized, "Session expired.") + } + + if err := models.UpdateSessionLastActive(s, session.ID); err != nil { + _ = s.Rollback() + return nil, err + } + + newRawToken, err := models.RotateRefreshToken(s, session) + if err != nil { + _ = s.Rollback() + if models.IsErrSessionNotFound(err) { + return nil, echo.NewHTTPError(http.StatusUnauthorized, "Refresh token already used.") + } + return nil, err + } + + u, err := user.GetUserByID(s, session.UserID) + if err != nil { + if user.IsErrUserStatusError(err) { + if _, delErr := s.Where("id = ?", session.ID).Delete(&models.Session{}); delErr != nil { + _ = s.Rollback() + return nil, delErr + } + if commitErr := s.Commit(); commitErr != nil { + return nil, commitErr + } + return nil, err + } + _ = s.Rollback() + return nil, err + } + + accessToken, err := NewUserJWTAuthtoken(u, session.ID) + if err != nil { + _ = s.Rollback() + return nil, err + } + + if err := s.Commit(); err != nil { + return nil, err + } + + return &RefreshResult{ + AccessToken: accessToken, + NewRefreshToken: newRawToken, + ExpiresIn: config.ServiceJWTTTLShort.GetInt64(), + IsLongSession: session.IsLongSession, + SessionID: session.ID, + }, nil +} diff --git a/pkg/routes/api/v1/login.go b/pkg/routes/api/v1/login.go index cf27244e5..7856534b9 100644 --- a/pkg/routes/api/v1/login.go +++ b/pkg/routes/api/v1/login.go @@ -18,7 +18,6 @@ package v1 import ( "net/http" - "time" "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" @@ -189,90 +188,23 @@ func RefreshToken(c *echo.Context) (err error) { 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) + result, err := auth.RefreshSession(cookie.Value) 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.") + if user2.IsErrUserStatusError(err) { + auth.ClearRefreshTokenCookie(c) } return err } - // 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 user2.IsErrUserStatusError(err) { - if _, delErr := s.Where("id = ?", session.ID).Delete(&models.Session{}); delErr != nil { - _ = s.Rollback() - return delErr - } - if commitErr := s.Commit(); commitErr != nil { - return commitErr - } - auth.ClearRefreshTokenCookie(c) - return err - } - if err != nil { - _ = s.Rollback() - return err - } - - 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 { + if result.IsLongSession { cookieMaxAge = int(config.ServiceJWTTTLLong.GetInt64()) } - auth.SetRefreshTokenCookie(c, newRawToken, cookieMaxAge) + auth.SetRefreshTokenCookie(c, result.NewRefreshToken, cookieMaxAge) c.Response().Header().Set("Cache-Control", "no-store") - return c.JSON(http.StatusOK, auth.Token{Token: t}) + return c.JSON(http.StatusOK, auth.Token{Token: result.AccessToken}) } // Logout deletes the current session from the server.