feat: add Session model with CRUD, permissions, and cleanup cron

- Session struct with UUID primary key, hashed refresh token, device
  info, IP address, and last-active tracking
- Token generation via generateHashedToken (SHA-256, 128 random bytes)
- CreateSession, GetSessionByRefreshToken, GetSessionByID
- Atomic RotateRefreshToken with WHERE on old hash to prevent replays
- ReadAll scoped to authenticated user (link shares rejected)
- Delete scoped to owning user (link shares rejected)
- Hourly cleanup cron for expired sessions based on is_long_session
- ErrSessionNotFound error type with HTTP 404 mapping
This commit is contained in:
kolaente 2026-02-25 09:33:40 +01:00
parent a6bdeb67b0
commit b3d0b2f697
6 changed files with 338 additions and 0 deletions

View File

@ -0,0 +1,27 @@
- id: "550e8400-e29b-41d4-a716-446655440001"
user_id: 1
token_hash: "ab7459bbf90562c0412ff2b43b55e2a8aa705e243f3959d2ad1ed3d3ee4e164e"
device_info: "Mozilla/5.0 Test Browser"
ip_address: "192.168.1.1"
is_long_session: false
last_active: 2099-01-01 00:00:00
created: 2024-01-01 00:00:00
# refresh token in plaintext: testtoken_session1
- id: "550e8400-e29b-41d4-a716-446655440002"
user_id: 1
token_hash: "87256b0b9dcb743578dbc84af87f53541ab2b57fa3cc863db026bc301a0d1e4c"
device_info: "Mobile App"
ip_address: "10.0.0.1"
is_long_session: true
last_active: 2099-01-01 00:00:00
created: 2024-01-02 00:00:00
# refresh token in plaintext: testtoken_session2
- id: "550e8400-e29b-41d4-a716-446655440003"
user_id: 2
token_hash: "2f5a3a993aea0aae984f4810cdc5ce4358ed808fd5b7fb5a6886178349f2e7ad"
device_info: "Other User Browser"
ip_address: "172.16.0.1"
is_long_session: false
last_active: 2099-01-01 00:00:00
created: 2024-01-03 00:00:00
# refresh token in plaintext: testtoken_session3

View File

@ -126,6 +126,7 @@ func FullInit() {
models.RegisterOldExportCleanupCron()
models.RegisterAddTaskToFilterViewCron()
user.RegisterTokenCleanupCron()
models.RegisterSessionCleanupCron()
user.RegisterDeletionNotificationCron()
openid.CleanupSavedOpenIDProviders()
openid.RegisterEmptyOpenIDTeamCleanupCron()

View File

@ -2155,3 +2155,32 @@ type ErrOpenIDBadRequestWithDetails struct {
func (err *ErrOpenIDBadRequestWithDetails) Error() string {
return err.Message
}
// ==============
// Session Errors
// ==============
// ErrSessionNotFound represents an error where a session was not found
type ErrSessionNotFound struct{}
// IsErrSessionNotFound checks if an error is ErrSessionNotFound.
func IsErrSessionNotFound(err error) bool {
_, ok := err.(*ErrSessionNotFound)
return ok
}
func (err *ErrSessionNotFound) Error() string {
return "Session not found"
}
// ErrCodeSessionNotFound holds the unique world-error code of this error
const ErrCodeSessionNotFound = 16001
// HTTPError holds the http error description
func (err *ErrSessionNotFound) HTTPError() web.HTTPError {
return web.HTTPError{
HTTPCode: http.StatusNotFound,
Code: ErrCodeSessionNotFound,
Message: "The session does not exist.",
}
}

237
pkg/models/sessions.go Normal file
View File

@ -0,0 +1,237 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"crypto/sha256"
"encoding/hex"
"time"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/cron"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/utils"
"code.vikunja.io/api/pkg/web"
"github.com/google/uuid"
"xorm.io/builder"
"xorm.io/xorm"
)
// Session represents an active user session with a refresh token.
type Session struct {
// The session UUID. Embedded in JWTs as the `sid` claim.
ID string `xorm:"varchar(36) not null unique pk" json:"id" param:"session"`
// The owning user.
UserID int64 `xorm:"bigint not null index" json:"-"`
// SHA-256 hash of the refresh token. Used for lookup on refresh.
TokenHash string `xorm:"varchar(64) not null unique index" json:"-"`
// The cleartext refresh token. Only populated on session creation, never stored.
RefreshToken string `xorm:"-" json:"refresh_token,omitempty"`
// User-Agent string from the login request.
DeviceInfo string `xorm:"text" json:"device_info"`
// IP address from the login request.
IPAddress string `xorm:"varchar(100)" json:"ip_address"`
// Whether this is a "remember me" session (controls max refresh lifetime).
IsLongSession bool `xorm:"not null default false" json:"-"`
// When this session was last refreshed.
LastActive time.Time `xorm:"not null" json:"last_active"`
// When this session was created (login time).
Created time.Time `xorm:"created not null" json:"created"`
web.Permissions `xorm:"-" json:"-"`
web.CRUDable `xorm:"-" json:"-"`
}
func (*Session) TableName() string {
return "sessions"
}
// HashSessionToken returns the hex-encoded SHA-256 hash of a token string.
// No salt needed because refresh tokens are high-entropy random strings,
// not human passwords — rainbow tables and dictionary attacks don't apply.
func HashSessionToken(token string) string {
h := sha256.Sum256([]byte(token))
return hex.EncodeToString(h[:])
}
// generateHashedToken creates a cryptographically random token and returns both
// the raw hex-encoded token (to give to the client) and its SHA-256 hash (to store).
func generateHashedToken() (rawToken, hash string, err error) {
tokenBytes, err := utils.CryptoRandomBytes(128)
if err != nil {
return "", "", err
}
rawToken = hex.EncodeToString(tokenBytes)
return rawToken, HashSessionToken(rawToken), nil
}
// CreateSession creates a new session record and generates a refresh token.
// Returns the session with RefreshToken populated (cleartext, shown only once).
func CreateSession(s *xorm.Session, userID int64, deviceInfo, ipAddress string, isLongSession bool) (*Session, error) {
rawToken, hash, err := generateHashedToken()
if err != nil {
return nil, err
}
session := &Session{
ID: uuid.New().String(),
UserID: userID,
TokenHash: hash,
DeviceInfo: deviceInfo,
IPAddress: ipAddress,
IsLongSession: isLongSession,
LastActive: time.Now(),
}
_, err = s.Insert(session)
if err != nil {
return nil, err
}
session.RefreshToken = rawToken
return session, nil
}
// GetSessionByRefreshToken finds a session by the SHA-256 hash of the provided token.
func GetSessionByRefreshToken(s *xorm.Session, token string) (*Session, error) {
hash := HashSessionToken(token)
session := &Session{}
has, err := s.Where("token_hash = ?", hash).Get(session)
if err != nil {
return nil, err
}
if !has {
return nil, &ErrSessionNotFound{}
}
return session, nil
}
// GetSessionByID finds a session by its UUID.
func GetSessionByID(s *xorm.Session, id string) (*Session, error) {
session := &Session{}
has, err := s.Where("id = ?", id).Get(session)
if err != nil {
return nil, err
}
if !has {
return nil, &ErrSessionNotFound{}
}
return session, nil
}
// ReadAll returns all sessions for the authenticated user.
func (sess *Session) ReadAll(s *xorm.Session, a web.Auth, _ string, page int, perPage int) (result interface{}, resultCount int, numberOfTotalItems int64, err error) {
// Link share tokens must not be able to list user sessions.
if _, is := a.(*LinkSharing); is {
return nil, 0, 0, ErrGenericForbidden{}
}
sessions := []*Session{}
var where builder.Cond = builder.Eq{"user_id": a.GetID()}
err = s.
Where(where).
OrderBy("last_active DESC").
Limit(getLimitFromPageIndex(page, perPage)).
Find(&sessions)
if err != nil {
return nil, 0, 0, err
}
totalCount, err := s.Where(where).Count(&Session{})
return sessions, len(sessions), totalCount, err
}
// Delete deletes a session by ID, scoped to the owning user.
func (sess *Session) Delete(s *xorm.Session, a web.Auth) error {
_, err := s.Where("id = ? AND user_id = ?", sess.ID, a.GetID()).Delete(&Session{})
return err
}
// UpdateSessionLastActive updates the last_active timestamp of a session.
func UpdateSessionLastActive(s *xorm.Session, sessionID string) error {
_, err := s.Where("id = ?", sessionID).
Cols("last_active").
Update(&Session{LastActive: time.Now()})
return err
}
// RotateRefreshToken atomically replaces the session's refresh token hash.
// The WHERE clause includes the old hash so that concurrent refreshes with the
// same token cannot both succeed — only the first UPDATE matches a row; the
// second sees 0 affected rows and returns ErrSessionNotFound.
func RotateRefreshToken(s *xorm.Session, session *Session) (newRawToken string, err error) {
newRawToken, newHash, err := generateHashedToken()
if err != nil {
return "", err
}
affected, err := s.Where("id = ? AND token_hash = ?", session.ID, session.TokenHash).
Cols("token_hash").
Update(&Session{TokenHash: newHash})
if err != nil {
return "", err
}
if affected == 0 {
// Another request already rotated this token — reject the replay.
return "", &ErrSessionNotFound{}
}
return newRawToken, nil
}
// DeleteAllUserSessions removes all sessions for a user (e.g., on password change).
func DeleteAllUserSessions(s *xorm.Session, userID int64) error {
_, err := s.Where("user_id = ?", userID).Delete(&Session{})
return err
}
// RegisterSessionCleanupCron registers a cron to delete sessions whose refresh
// tokens have expired. Uses is_long_session to pick the right cutoff so short
// sessions don't linger for the full long TTL. Runs hourly.
func RegisterSessionCleanupCron() {
const logPrefix = "[Session Cleanup Cron] "
err := cron.Schedule("0 * * * *", func() {
s := db.NewSession()
defer s.Close()
now := time.Now()
shortMaxAge := time.Duration(config.ServiceJWTTTL.GetInt64()) * time.Second
longMaxAge := time.Duration(config.ServiceJWTTTLLong.GetInt64()) * time.Second
// Delete short sessions older than ServiceJWTTTL
// and long sessions older than ServiceJWTTTLLong
deleted, err := s.
Where("(is_long_session = ? AND last_active < ?) OR (is_long_session = ? AND last_active < ?)",
false, now.Add(-shortMaxAge),
true, now.Add(-longMaxAge)).
Delete(&Session{})
if err != nil {
log.Errorf(logPrefix+"Error removing stale sessions: %s", err)
return
}
if deleted > 0 {
log.Debugf(logPrefix+"Deleted %d stale sessions", deleted)
}
})
if err != nil {
log.Fatalf("Could not register session cleanup cron: %s", err)
}
}

View File

@ -0,0 +1,43 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"code.vikunja.io/api/pkg/web"
"xorm.io/xorm"
)
func (sess *Session) CanDelete(s *xorm.Session, a web.Auth) (bool, error) {
// Link share tokens must not be able to delete user sessions.
if _, is := a.(*LinkSharing); is {
return false, nil
}
session, err := GetSessionByID(s, sess.ID)
if err != nil {
return false, err
}
if session.UserID != a.GetID() {
return false, nil
}
*sess = *session
return true, nil
}
func (sess *Session) CanCreate(_ *xorm.Session, _ web.Auth) (bool, error) {
return true, nil
}

View File

@ -74,6 +74,7 @@ func SetupTests() {
"project_views",
"task_positions",
"task_buckets",
"sessions",
)
if err != nil {
log.Fatal(err)