fix(auth): recover refresh rotations and configure session lifetime
This commit is contained in:
@@ -19,6 +19,9 @@ UPLOAD_ALLOWED_EXTENSIONS=
|
||||
PANEL_PUBLIC_URL=http://127.0.0.1:8088
|
||||
# Optional proxy networks. X-Forwarded-For is ignored unless the peer is in these CIDRs.
|
||||
TRUSTED_PROXY_CIDRS=
|
||||
# Absolute login lifetime (Go duration: 24h, 168h for 7 days). Applies to new logins.
|
||||
# Refresh does not extend this deadline. Invalid/non-positive values prevent startup.
|
||||
SESSION_TTL=24h
|
||||
# Trusted executable runtime build source. Do not embed credentials in this URL.
|
||||
DSH_RELEASE_REPOSITORY=https://git.misaka.ren/m1saka/dsh-web.git
|
||||
DSH_RELEASE_BRANCH=dockerfile
|
||||
|
||||
@@ -117,3 +117,12 @@ bash scripts/check-trial.sh
|
||||
该脚本执行本地自动化测试、race、静态检查及前后端构建,不启动容器、不安装依赖、不部署。需提前准备 Go/Node/npm 和前端依赖。可通过 `TEST_TMPDIR` 指定已存在的临时目录。面板构建单测已隔离真实磁盘空闲量;生产磁盘检查仍保留。`image/init-profile.test.mjs` 属于发布分支的 runtime 测试,不在 main 的检查脚本中运行。
|
||||
|
||||
`scripts/test-panel.mjs` 必须指向独立测试部署,通过 `TEST_PANEL_URL`、`TEST_USERNAME`、`TEST_PASSWORD` 指定访问参数;`TEST_UPGRADE=1` 包含同镜像替换升级。运行前需由管理员通过 Git 发布构建准备已验证且准入的本地 runtime 镜像;可用 `TEST_FROM_VERSION` / `TEST_TO_VERSION` 选择源和目标版本。脚本不提交构建,只复用这些镜像,会创建及删除测试实例,删除时一并清理实例数据。不要对生产账号运行。`scripts/smoke-image.sh <image-id>` 和 `scripts/test-mobile-image.mjs <image-id>` 仅测试已有镜像的运行行为,不读取 main 的构建源;仅在隔离测试环境使用。旧构建脚本和构建后创建意图的 live 测试已删除。
|
||||
# Session lifetime
|
||||
|
||||
Set `SESSION_TTL=24h` in `.env` (default), or `SESSION_TTL=168h` for seven days.
|
||||
Use Go duration units such as `h`, `m`, `s` (not `d`); the value must be a positive
|
||||
whole number of seconds. Recreate the panel container after changing it:
|
||||
`docker compose up -d --force-recreate panel`. New logins use the new lifetime;
|
||||
existing sessions retain their absolute expiry and refresh does not extend it.
|
||||
Access tokens remain valid for five minutes. `.env` is read by Docker Compose;
|
||||
when running the binary directly, export `SESSION_TTL` in its environment.
|
||||
|
||||
@@ -21,6 +21,7 @@ services:
|
||||
HOST_DATA_ROOT: ${DSH_HOST_DATA_ROOT:?Set an absolute host data directory}
|
||||
PANEL_PUBLIC_URL: ${PANEL_PUBLIC_URL:-http://127.0.0.1:8088}
|
||||
TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-}
|
||||
SESSION_TTL: ${SESSION_TTL:-24h}
|
||||
DSH_RELEASE_REPOSITORY: ${DSH_RELEASE_REPOSITORY:-https://git.misaka.ren/m1saka/dsh-web.git}
|
||||
DSH_RELEASE_BRANCH: ${DSH_RELEASE_BRANCH:-dockerfile}
|
||||
WEB_DIR: /app/web/dist
|
||||
|
||||
@@ -6,8 +6,17 @@
|
||||
session IDs never authenticate. Internal callers and test fixtures must also
|
||||
attach a signed access cookie.
|
||||
- Refresh is a random 256-bit HttpOnly cookie, scoped to `/api`, with only its
|
||||
SHA-256 hash stored in SQLite. The session has a 24-hour absolute lifetime.
|
||||
Conditional SQL update makes rotation single-use even across concurrent requests.
|
||||
SHA-256 hash stored in SQLite. The session has an absolute lifetime configured
|
||||
by SESSION_TTL (default 24h), applied only to new logins.
|
||||
Session, refresh and CSRF cookies persist across browser restarts with explicit
|
||||
Max-Age/Expires bounded by that database expiry; rotation does not extend it.
|
||||
Transactional rotation stores current and previous token hashes. A keyed,
|
||||
session-bound successor derivation makes retries of the previous token return
|
||||
the same current token, including after response loss or server restart. Only
|
||||
one previous generation is accepted (until the next rotation or session expiry);
|
||||
this intentionally permits that generation to be replayed with the required
|
||||
session/Origin/CSRF checks. Older and unknown tokens are rejected. Plaintext
|
||||
refresh credentials are never stored.
|
||||
Logout deletes the database session and its refresh binding. Both refresh and
|
||||
logout retain mandatory exact Origin and session-bound CSRF checks, even when
|
||||
access has expired. Other operations require valid access.
|
||||
|
||||
+5
-1
@@ -226,7 +226,11 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, 500, "session error")
|
||||
return
|
||||
}
|
||||
a.authCookies(w, r, sid, u.ID, csrf, token)
|
||||
if e = a.authCookies(w, r, sid, u.ID, csrf, token); e != nil {
|
||||
a.s.DeleteSession(sid)
|
||||
fail(w, 500, "session error")
|
||||
return
|
||||
}
|
||||
a.s.AddAudit(u.ID, "login", u.ID, "")
|
||||
writeJSON(w, map[string]any{"user": u, "csrf_token": csrf})
|
||||
}
|
||||
|
||||
+70
-27
@@ -63,9 +63,51 @@ func (a *API) initAuth() error {
|
||||
}
|
||||
_, err = a.s.DB.Exec(`CREATE TABLE IF NOT EXISTS auth_refresh(sid TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, token_hash TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS auth_failures(username TEXT PRIMARY KEY, failures INTEGER NOT NULL, locked_until INTEGER NOT NULL);`)
|
||||
if err == nil {
|
||||
_, err = a.s.DB.Exec(`CREATE TABLE IF NOT EXISTS auth_refresh_previous(sid TEXT PRIMARY KEY REFERENCES auth_refresh(sid) ON DELETE CASCADE, token_hash TEXT NOT NULL);`)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Derivation makes retries return the same successor without storing plaintext
|
||||
// credentials. Domain and session binding prevent cross-session token reuse.
|
||||
func (a *API) nextRefresh(sid, old string) string {
|
||||
mac := hmac.New(sha256.New, a.authKey)
|
||||
mac.Write([]byte("refresh-successor\x00" + sid + "\x00" + old))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func (a *API) rotateRefresh(sid, old string) (string, error) {
|
||||
tx, err := a.s.DB.Begin()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var current, previous string
|
||||
err = tx.QueryRow(`SELECT r.token_hash, COALESCE(p.token_hash, '')
|
||||
FROM auth_refresh r JOIN sessions s ON s.id=r.sid
|
||||
LEFT JOIN auth_refresh_previous p ON p.sid=r.sid
|
||||
WHERE r.sid=? AND s.expires>?`, sid, time.Now().Unix()).Scan(¤t, &previous)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
hash := refreshHash(old)
|
||||
next := a.nextRefresh(sid, old)
|
||||
if hash == previous && refreshHash(next) == current {
|
||||
return next, tx.Commit()
|
||||
}
|
||||
if hash != current {
|
||||
return "", errAuthentication
|
||||
}
|
||||
if _, err = tx.Exec("UPDATE auth_refresh SET token_hash=? WHERE sid=?", refreshHash(next), sid); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err = tx.Exec("INSERT INTO auth_refresh_previous(sid,token_hash) VALUES(?,?) ON CONFLICT(sid) DO UPDATE SET token_hash=excluded.token_hash", sid, hash); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return next, tx.Commit()
|
||||
}
|
||||
|
||||
type accessClaims struct {
|
||||
Subject string `json:"sub"`
|
||||
Session string `json:"sid"`
|
||||
@@ -130,18 +172,34 @@ func (a *API) secureCookies(r *http.Request) bool {
|
||||
}
|
||||
return secure
|
||||
}
|
||||
func (a *API) authCookies(w http.ResponseWriter, r *http.Request, sid, uid, csrf, refresh string) {
|
||||
func (a *API) authCookies(w http.ResponseWriter, r *http.Request, sid, uid, csrf, refresh string) error {
|
||||
// Persist recovery cookies across mobile browser process eviction, but never
|
||||
// extend them beyond the database session's absolute expiry on refresh.
|
||||
var expires int64
|
||||
if err := a.s.DB.QueryRow("SELECT expires FROM sessions WHERE id=?", sid).Scan(&expires); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
remaining := int(expires - now.Unix())
|
||||
if remaining <= 0 {
|
||||
return errAuthentication
|
||||
}
|
||||
for _, c := range []*http.Cookie{
|
||||
{Name: "dsh_session", Value: sid, Path: "/", HttpOnly: true},
|
||||
{Name: "dsh_session", Value: sid, Path: "/", HttpOnly: true, MaxAge: remaining},
|
||||
{Name: "dsh_access", Value: a.accessJWT(sid, uid, time.Now().Add(accessLifetime)), Path: "/", HttpOnly: true, MaxAge: int(accessLifetime.Seconds())},
|
||||
{Name: "dsh_refresh", Value: refresh, Path: "/api", HttpOnly: true},
|
||||
{Name: "dsh_csrf", Value: csrf, Path: "/"},
|
||||
{Name: "dsh_refresh", Value: refresh, Path: "/api", HttpOnly: true, MaxAge: remaining},
|
||||
{Name: "dsh_csrf", Value: csrf, Path: "/", MaxAge: remaining},
|
||||
} {
|
||||
c.Expires = time.Unix(expires, 0)
|
||||
if c.Name == "dsh_access" {
|
||||
c.Expires = now.Add(accessLifetime)
|
||||
}
|
||||
c.Secure = a.secureCookies(r)
|
||||
c.SameSite = http.SameSiteLaxMode
|
||||
http.SetCookie(w, c)
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
return nil
|
||||
}
|
||||
func (a *API) refresh(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
@@ -181,34 +239,19 @@ func (a *API) refresh(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, 401, "authentication required")
|
||||
return
|
||||
}
|
||||
token, err := refreshToken()
|
||||
token, err := a.rotateRefresh(sid.Value, old.Value)
|
||||
if err != nil {
|
||||
fail(w, 500, "session error")
|
||||
return
|
||||
}
|
||||
result, err := a.s.DB.Exec("UPDATE auth_refresh SET token_hash=? WHERE sid=? AND token_hash=? AND EXISTS(SELECT 1 FROM sessions WHERE id=? AND expires>?)", refreshHash(token), sid.Value, refreshHash(old.Value), sid.Value, time.Now().Unix())
|
||||
if err != nil {
|
||||
fail(w, 500, "session error")
|
||||
return
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
fail(w, 500, "session error")
|
||||
return
|
||||
}
|
||||
if n != 1 {
|
||||
var active bool
|
||||
err = a.s.DB.QueryRow("SELECT EXISTS(SELECT 1 FROM sessions JOIN auth_refresh ON auth_refresh.sid=sessions.id WHERE sessions.id=? AND sessions.expires>?)", sid.Value, time.Now().Unix()).Scan(&active)
|
||||
if err != nil {
|
||||
fail(w, 500, "session error")
|
||||
} else if active {
|
||||
fail(w, 409, "refresh_conflict")
|
||||
} else {
|
||||
if errors.Is(err, sql.ErrNoRows) || errors.Is(err, errAuthentication) {
|
||||
fail(w, 401, "authentication required")
|
||||
} else {
|
||||
fail(w, 500, "session error")
|
||||
}
|
||||
return
|
||||
}
|
||||
a.authCookies(w, r, sid.Value, u.ID, csrf, token)
|
||||
if err = a.authCookies(w, r, sid.Value, u.ID, csrf, token); err != nil {
|
||||
fail(w, 500, "session error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"user": u, "csrf_token": csrf})
|
||||
}
|
||||
func (a *API) accountLocked(username string) (bool, error) {
|
||||
|
||||
@@ -107,11 +107,11 @@ func TestAccessRefreshLifecycle(t *testing.T) {
|
||||
if cookieValue(fresh, "dsh_refresh") == cookieValue(cs, "dsh_refresh") {
|
||||
t.Fatal("not rotated")
|
||||
}
|
||||
if w = req("POST", "/api/refresh", "", csrf, cs); w.Code != 409 {
|
||||
t.Fatal("replay accepted", w.Code)
|
||||
if w = req("POST", "/api/refresh", "", csrf, cs); w.Code != 200 {
|
||||
t.Fatal("retry rejected", w.Code)
|
||||
}
|
||||
if len(w.Result().Cookies()) != 0 || !strings.Contains(w.Body.String(), "refresh_conflict") {
|
||||
t.Fatal("conflict must not issue credentials", w.Header(), w.Body.String())
|
||||
if cookieValue(w.Result().Cookies(), "dsh_refresh") != cookieValue(fresh, "dsh_refresh") {
|
||||
t.Fatal("retry must return the same successor")
|
||||
}
|
||||
if w = req("GET", "/api/me", "", "", fresh); w.Code != 200 {
|
||||
t.Fatal(w.Code)
|
||||
@@ -126,6 +126,72 @@ func TestAccessRefreshLifecycle(t *testing.T) {
|
||||
t.Fatal("logout refresh valid")
|
||||
}
|
||||
}
|
||||
func TestPersistentCookiesRecoverAfterBrowserRestart(t *testing.T) {
|
||||
a, req := authFixture(t)
|
||||
w := req("POST", "/api/login", `{"username":"alice","password":"password-123"}`, "", nil)
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
cookies := w.Result().Cookies()
|
||||
sid := cookieValue(cookies, "dsh_session")
|
||||
var expires int64
|
||||
if err := a.s.DB.QueryRow("SELECT expires FROM sessions WHERE id=?", sid).Scan(&expires); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var restored []*http.Cookie
|
||||
for _, c := range cookies {
|
||||
if c.MaxAge <= 0 || c.Expires.IsZero() {
|
||||
t.Fatalf("nonpersistent cookie: %s", c.Name)
|
||||
}
|
||||
if c.Name == "dsh_access" {
|
||||
continue
|
||||
} // Access has expired while backgrounded.
|
||||
if c.Expires.Unix() != expires {
|
||||
t.Fatalf("wrong expiry: %s", c.Name)
|
||||
}
|
||||
restored = append(restored, c)
|
||||
}
|
||||
if w = req("GET", "/api/me", "", "", restored); w.Code != 401 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
// Simulate an older session without sleeping; rotation must not reset its TTL.
|
||||
expires = time.Now().Add(time.Hour).Unix()
|
||||
if _, err := a.s.DB.Exec("UPDATE sessions SET expires=? WHERE id=?", expires, sid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w = req("POST", "/api/refresh", "", cookieValue(restored, "dsh_csrf"), restored)
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
fresh := w.Result().Cookies()
|
||||
for _, c := range fresh {
|
||||
if c.Name == "dsh_access" {
|
||||
if c.MaxAge != int(accessLifetime.Seconds()) {
|
||||
t.Fatal("access lifetime changed")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if c.Expires.Unix() != expires || c.MaxAge <= 0 || c.MaxAge > 3600 {
|
||||
t.Fatalf("session extended: %s", c.Name)
|
||||
}
|
||||
}
|
||||
if w = req("GET", "/api/me", "", "", fresh); w.Code != 200 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
w = req("POST", "/api/logout", "", cookieValue(fresh, "dsh_csrf"), fresh)
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.MaxAge >= 0 {
|
||||
t.Fatalf("cookie not cleared: %s", c.Name)
|
||||
}
|
||||
}
|
||||
if w = req("GET", "/api/me", "", "", fresh); w.Code != 401 {
|
||||
t.Fatal("revoked session accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountLockAndPersistentKey(t *testing.T) {
|
||||
a, req := authFixture(t)
|
||||
for i := 0; i < 5; i++ {
|
||||
@@ -198,7 +264,7 @@ func TestRefreshOriginConcurrentRotationAndJWTBinding(t *testing.T) {
|
||||
for code := range codes {
|
||||
counts[code]++
|
||||
}
|
||||
if counts[200] != 1 || counts[409] != 1 {
|
||||
if counts[200] != 2 {
|
||||
t.Fatal(counts)
|
||||
}
|
||||
b := New(a.s, a.root)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRefreshLostResponseRecovery(t *testing.T) {
|
||||
a, req := authFixture(t)
|
||||
w := req("POST", "/api/login", `{"username":"alice","password":"password-123"}`, "", nil)
|
||||
old := w.Result().Cookies()
|
||||
csrf, sid := cookieValue(old, "dsh_csrf"), cookieValue(old, "dsh_session")
|
||||
w = req("POST", "/api/refresh", "", csrf, old)
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
successor := cookieValue(w.Result().Cookies(), "dsh_refresh")
|
||||
// Lose the response, expire access and restart the server: recovery is durable
|
||||
// and does not depend on an active access cookie or a short grace timer.
|
||||
for _, c := range old {
|
||||
if c.Name == "dsh_access" {
|
||||
c.Value = a.accessJWT(sid, "alice", time.Now().Add(-time.Hour))
|
||||
}
|
||||
}
|
||||
b := New(a.s, a.root)
|
||||
b.origin = a.origin
|
||||
r := httptest.NewRequest("POST", "/api/refresh", strings.NewReader(""))
|
||||
r.Header.Set("Origin", a.origin)
|
||||
r.Header.Set("X-CSRF-Token", csrf)
|
||||
for _, c := range old {
|
||||
r.AddCookie(c)
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
b.Handler().ServeHTTP(w, r)
|
||||
if w.Code != 200 || cookieValue(w.Result().Cookies(), "dsh_refresh") != successor {
|
||||
t.Fatal("lost response recovery failed", w.Code)
|
||||
}
|
||||
current := w.Result().Cookies()
|
||||
w = req("POST", "/api/refresh", "", csrf, current)
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
if w = req("POST", "/api/refresh", "", csrf, old); w.Code != 401 || len(w.Result().Cookies()) != 0 {
|
||||
t.Fatal("two-generation-old token accepted", w.Code)
|
||||
}
|
||||
bad := []*http.Cookie{{Name: "dsh_session", Value: sid}, {Name: "dsh_refresh", Value: "invalid"}}
|
||||
if w = req("POST", "/api/refresh", "", csrf, bad); w.Code != 401 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
if _, err := a.s.DB.Exec("UPDATE sessions SET expires=? WHERE id=?", time.Now().Unix(), sid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w = req("POST", "/api/refresh", "", csrf, current); w.Code == 200 || len(w.Result().Cookies()) != 0 {
|
||||
t.Fatal("expired session recovered")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSessionTTL(t *testing.T) {
|
||||
for _, value := range []string{"", "24h", "168h", "60s"} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
t.Setenv("SESSION_TTL", value)
|
||||
s, err := Open(filepath.Join(t.TempDir(), "db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
before := time.Now().Unix()
|
||||
sid, _, err := s.Session("user")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var expiry int64
|
||||
if err = s.DB.QueryRow("SELECT expires FROM sessions WHERE id=?", sid).Scan(&expiry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ttl, _ := sessionLifetime(value)
|
||||
if expiry < before+int64(ttl.Seconds()) || expiry > time.Now().Unix()+int64(ttl.Seconds()) {
|
||||
t.Fatal(expiry)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, value := range []string{"0", "-1h", "7d", "bad", "500ms", "1.5s"} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
t.Setenv("SESSION_TTL", value)
|
||||
if s, err := Open(filepath.Join(t.TempDir(), "db")); err == nil {
|
||||
s.Close()
|
||||
t.Fatal("invalid TTL accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+24
-6
@@ -4,7 +4,9 @@ import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
_ "modernc.org/sqlite"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -27,12 +29,17 @@ type Instance struct {
|
||||
Created time.Time `json:"created_at"`
|
||||
}
|
||||
type Store struct {
|
||||
DB *sql.DB
|
||||
closed chan struct{}
|
||||
closeOnce sync.Once
|
||||
DB *sql.DB
|
||||
sessionTTL time.Duration
|
||||
closed chan struct{}
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func Open(path string) (*Store, error) {
|
||||
ttl, err := sessionLifetime(os.Getenv("SESSION_TTL"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, e := sql.Open("sqlite", path)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
@@ -47,7 +54,18 @@ func Open(path string) (*Store, error) {
|
||||
db.Close()
|
||||
return nil, e
|
||||
}
|
||||
return &Store{DB: db, closed: make(chan struct{})}, nil
|
||||
return &Store{DB: db, sessionTTL: ttl, closed: make(chan struct{})}, nil
|
||||
}
|
||||
|
||||
func sessionLifetime(value string) (time.Duration, error) {
|
||||
if value == "" {
|
||||
return 24 * time.Hour, nil
|
||||
}
|
||||
ttl, err := time.ParseDuration(value)
|
||||
if err != nil || ttl < time.Second || ttl%time.Second != 0 {
|
||||
return 0, fmt.Errorf("SESSION_TTL must be a positive whole-second duration, e.g. 24h or 168h")
|
||||
}
|
||||
return ttl, nil
|
||||
}
|
||||
func (s *Store) Close() { s.closeOnce.Do(func() { close(s.closed); s.DB.Close() }) }
|
||||
func (s *Store) Done() <-chan struct{} { return s.closed }
|
||||
@@ -85,14 +103,14 @@ func (s *Store) Password(name string) (string, error) {
|
||||
}
|
||||
func (s *Store) Session(uid string) (string, string, error) {
|
||||
id, csrf := ID(), ID()
|
||||
_, e := s.DB.Exec("INSERT INTO sessions VALUES(?,?,?,?)", id, uid, csrf, time.Now().Add(24*time.Hour).Unix())
|
||||
_, e := s.DB.Exec("INSERT INTO sessions VALUES(?,?,?,?)", id, uid, csrf, time.Now().Add(s.sessionTTL).Unix())
|
||||
return id, csrf, e
|
||||
}
|
||||
func (s *Store) SessionUser(id string) (User, string, error) {
|
||||
var uid, csrf string
|
||||
var exp int64
|
||||
e := s.DB.QueryRow("SELECT uid,csrf,expires FROM sessions WHERE id=?", id).Scan(&uid, &csrf, &exp)
|
||||
if e != nil || exp < time.Now().Unix() {
|
||||
if e != nil || exp <= time.Now().Unix() {
|
||||
return User{}, "", sql.ErrNoRows
|
||||
}
|
||||
u, e := s.UserByID(uid)
|
||||
|
||||
Reference in New Issue
Block a user