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:
parent
a6bdeb67b0
commit
b3d0b2f697
|
|
@ -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
|
||||
|
|
@ -126,6 +126,7 @@ func FullInit() {
|
|||
models.RegisterOldExportCleanupCron()
|
||||
models.RegisterAddTaskToFilterViewCron()
|
||||
user.RegisterTokenCleanupCron()
|
||||
models.RegisterSessionCleanupCron()
|
||||
user.RegisterDeletionNotificationCron()
|
||||
openid.CleanupSavedOpenIDProviders()
|
||||
openid.RegisterEmptyOpenIDTeamCleanupCron()
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -74,6 +74,7 @@ func SetupTests() {
|
|||
"project_views",
|
||||
"task_positions",
|
||||
"task_buckets",
|
||||
"sessions",
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
|
|
|
|||
Loading…
Reference in New Issue