pub struct AdminSession {
pub token_hash: String,
pub user_id: Uuid,
pub csrf_token: String,
pub state: String,
pub mfa_attempts: i64,
pub created_at: i64,
pub expires_at: i64,
pub last_seen_at: i64,
pub created_ip: Option<String>,
pub user_agent: Option<String>,
}Expand description
One logged-in browser session of an crate::admin_user::AdminUser.
This layer never sees the session token. token_hash arrives already
hashed from webadmin::session, which is the only place the plaintext
exists: it goes into a Set-Cookie and is never written down. A read of
this table – a backup, a .dump, an injection – therefore yields nothing
that can be replayed. That is also why there is no bare find_by_id: the
hash is the lookup key, and knowing it means already holding the token.
AdminSession::find_by_user_and_fingerprint is not that: it resolves the
displayed fingerprint (see to_json’s id) back to a row, but only
within one caller-supplied user_id – so reaching a row still requires
already being authorized to see that user’s sessions, the web panel’s
“revoke one of these” buttons being the only reason it exists.
§Methods
create: persist a session for a userfind_by_token_hash: the per-request resolution pathfind_by_user_and_fingerprint: resolve a displayedidback to a row, scoped to the one user it can belong to – see the note theretouch: advance the idle deadlinedelete/delete_for_user/delete_for_user_except: logout, revoke all, and the “keep the session doing the changing” case a password change needslist_all: admin CLI visibilitycleanup: the reaper’s sweepto_json: admin-facing rendering (never the token hash or the CSRF token)
Fields§
§token_hash: StringHex-encoded SHA-256 of the cookie’s bearer token.
user_id: Uuid§csrf_token: StringPer-session CSRF token, plaintext: it authorises nothing on its own.
state: Stringactive, or pending_mfa once a second factor exists to be outstanding.
mfa_attempts: i64Second-factor codes rejected against this session. Advanced only by
AdminSession::record_mfa_failure, which is where the reasoning is.
created_at: i64§expires_at: i64Absolute deadline. Never extended.
last_seen_at: i64Idle deadline, advanced by AdminSession::touch.
created_ip: Option<String>Forensics only – never compared against the request being served.
user_agent: Option<String>Implementations§
Source§impl AdminSession
impl AdminSession
Sourcepub async fn create(
new: NewSession<'_>,
ttl: Duration,
database: &Database,
) -> Result<AdminSession, Error>
pub async fn create( new: NewSession<'_>, ttl: Duration, database: &Database, ) -> Result<AdminSession, Error>
Persists a fresh active session expiring ttl from now.
Sourcepub async fn create_pending(
new: NewSession<'_>,
ttl: Duration,
database: &Database,
) -> Result<AdminSession, Error>
pub async fn create_pending( new: NewSession<'_>, ttl: Duration, database: &Database, ) -> Result<AdminSession, Error>
Persists a fresh pending_mfa session: a password was accepted and
nothing more.
ttl is webadmin::session::PENDING_MFA_TTL, not the configured session
lifetime – a half-authenticated row should not outlive the tab that
created it, and that short absolute deadline is one of the two bounds on
how long an attacker holding a password may keep guessing codes (the
other is AdminSession::record_mfa_failure).
The reaper needs no special case for these: expires_at is set the same
way, so cleanup’s existing expires_at <= ? sweeps them.
Sourcepub async fn promote(
pending_token_hash: &str,
new_token_hash: &str,
new_csrf_token: &str,
ttl: Duration,
database: &Database,
) -> Result<Option<AdminSession>, Error>
pub async fn promote( pending_token_hash: &str, new_token_hash: &str, new_csrf_token: &str, ttl: Duration, database: &Database, ) -> Result<Option<AdminSession>, Error>
Replaces a pending_mfa session with a fresh active one: a new token,
a new CSRF token, a full ttl, and the same user and forensics.
A rotation, not an UPDATE state. The pending token is a real bearer
token that a browser stored and that has crossed the wire, minted before
authentication completed; its privilege level changing means its value
changes – the same rule that makes sign_in delete whatever session the
request already carried. The CSRF token rotates for free, which matters
because the challenge page had to be handed one.
One transaction, and the DELETE’s rows_affected is the concurrency
guard: two submissions of one code promote exactly once. None means
nothing pending sat under pending_token_hash – already promoted,
already swept, or never there.
The side effect worth having: with no setter for state anywhere, the
column is write-once at INSERT, so no code path can move a session
between states in place.
Sourcepub async fn record_mfa_failure(
token_hash: &str,
database: &Database,
) -> Result<Option<i64>, Error>
pub async fn record_mfa_failure( token_hash: &str, database: &Database, ) -> Result<Option<i64>, Error>
Counts one rejected second-factor code against this session, returning the new total.
The bound an attacker cannot shed. webadmin::session::LoginLimiter
keys on the peer address, and a pending_mfa cookie is valid from any
address on purpose (see created_ip: pinning breaks CGNAT and mobile).
Somebody holding a correct password could therefore mint one pending
session and spend admin.login_max_attempts guesses per source address,
which a single IPv6 /64 supplies 2^64 of. This counter travels with the
session instead, so rotating addresses buys nothing.
One UPDATE ... RETURNING, which is also what makes it race-free: this
listener carries no admission control by design, so a read-then-write
would let K concurrent submissions all observe zero and each get a free
guess. None means the row is already gone – swept, promoted, or
deleted by a concurrent submission that hit the cap first.
Sourcepub async fn find_by_token_hash(
token_hash: &str,
database: &Database,
) -> Result<Option<AdminSession>, Error>
pub async fn find_by_token_hash( token_hash: &str, database: &Database, ) -> Result<Option<AdminSession>, Error>
The per-request resolution path. Returns the row whatever its state –
expiry, idleness and state are the caller’s to judge, since each maps
to a different refusal.
Sourcepub async fn find_by_user_and_fingerprint(
user_id: Uuid,
fingerprint_id: &str,
database: &Database,
) -> Result<Option<AdminSession>, Error>
pub async fn find_by_user_and_fingerprint( user_id: Uuid, fingerprint_id: &str, database: &Database, ) -> Result<Option<AdminSession>, Error>
Resolves a session’s displayed fingerprint (to_json’s id) back to a
row, scoped to user_id – the web panel’s “revoke this session”
button, for either the caller’s own sessions or, on the operators
surface, another operator’s.
Scoping to one user rather than searching every session by fingerprint
is the whole security property: a caller who has not already been
authorized to act on user_id cannot reach a row this way even by
observing its fingerprint elsewhere. Built over Self::list_all
rather than a dedicated query – one operator’s live sessions are a
small scan, and the fingerprint is derived, not a column, so there is
nothing for SQL to compare it against directly.
Sourcepub async fn touch(&mut self, database: &Database) -> Result<(), Error>
pub async fn touch(&mut self, database: &Database) -> Result<(), Error>
Advances the idle deadline. Callers rate-limit this – see
webadmin::session::SESSION_TOUCH_INTERVAL – because a polling page
would otherwise take the WAL writer lock on every request.
Sourcepub async fn delete(
token_hash: &str,
database: &Database,
) -> Result<bool, Error>
pub async fn delete( token_hash: &str, database: &Database, ) -> Result<bool, Error>
Logout. Returns whether a row existed.
Sourcepub async fn delete_for_user(
user_id: Uuid,
database: &Database,
) -> Result<u64, Error>
pub async fn delete_for_user( user_id: Uuid, database: &Database, ) -> Result<u64, Error>
Revokes every session of one user – admin session revoke --user, and
the “log me out everywhere” case. Returns how many went.
Sourcepub async fn delete_for_user_except(
user_id: Uuid,
keep_token_hash: &str,
database: &Database,
) -> Result<u64, Error>
pub async fn delete_for_user_except( user_id: Uuid, keep_token_hash: &str, database: &Database, ) -> Result<u64, Error>
Revokes every session of one user except the one named – what a password change needs, so the operator making it is not logged out by their own action while every other browser is.
Sourcepub async fn delete_all(database: &Database) -> Result<u64, Error>
pub async fn delete_all(database: &Database) -> Result<u64, Error>
Revokes every session on the server – admin session revoke --all,
the “log everybody out” lever after a scare. Returns how many went.
Sourcepub async fn list_all(
user_id: Option<Uuid>,
database: &Database,
) -> Result<Vec<AdminSession>, Error>
pub async fn list_all( user_id: Option<Uuid>, database: &Database, ) -> Result<Vec<AdminSession>, Error>
Every session, or every session of one user, newest first.
A scan, not a listing: its one caller is
admin::users::confirm_delete_user, which counts what the delete will
cascade to so the prompt can name it. Nothing renders it – see
AdminUser::list_all
for why that is what lets it sit beside AdminSession::search.
Sourcepub async fn search(
user_id: Option<Uuid>,
limit: i64,
offset: i64,
database: &Database,
) -> Result<(Vec<AdminSession>, i64), Error>
pub async fn search( user_id: Option<Uuid>, limit: i64, offset: i64, database: &Database, ) -> Result<(Vec<AdminSession>, i64), Error>
One page of the same listing, plus the total those filters match unpaged.
admin session list’s window. Newest first like Self::list_all, and
tie-broken on token_hash for the same reason Eab::search breaks on
kid: created_at is a whole second, and a browser signing in twice
lands two rows inside one.
Sourcepub async fn cleanup(
idle_timeout: Duration,
database: &Database,
) -> Result<u64, Error>
pub async fn cleanup( idle_timeout: Duration, database: &Database, ) -> Result<u64, Error>
The reaper’s sweep: everything past its absolute deadline, plus
everything idle longer than idle_timeout. Unlike nonces, sessions
outlive a restart, so a startup-only sweep would leak.
Sourcepub fn is_expired(&self, now: i64) -> bool
pub fn is_expired(&self, now: i64) -> bool
Past its absolute deadline.
Sourcepub fn is_idle(&self, now: i64, idle_timeout: Duration) -> bool
pub fn is_idle(&self, now: i64, idle_timeout: Duration) -> bool
Unused for longer than idle_timeout.
Sourcepub fn is_active(&self) -> bool
pub fn is_active(&self) -> bool
Whether the session has completed authentication. A pending_mfa
session has a valid password behind it and nothing more.
Sourcepub fn to_json(&self) -> Value
pub fn to_json(&self) -> Value
The admin-facing rendering. Never the token hash (it is the lookup
key, and printing it in admin session list would put every live
session’s key on a terminal) nor the CSRF token.
id is a fingerprint of the hash: enough to name one session to
admin session revoke, not enough to reconstruct the key.
Trait Implementations§
Source§impl Clone for AdminSession
impl Clone for AdminSession
Auto Trait Implementations§
impl Freeze for AdminSession
impl RefUnwindSafe for AdminSession
impl Send for AdminSession
impl Sync for AdminSession
impl Unpin for AdminSession
impl UnsafeUnpin for AdminSession
impl UnwindSafe for AdminSession
Blanket Implementations§
Source§impl<'a, T, E> AsTaggedExplicit<'a, E> for Twhere
T: 'a,
impl<'a, T, E> AsTaggedExplicit<'a, E> for Twhere
T: 'a,
Source§impl<'a, T, E> AsTaggedImplicit<'a, E> for Twhere
T: 'a,
impl<'a, T, E> AsTaggedImplicit<'a, E> for Twhere
T: 'a,
Source§impl<T> BorrowMut<T> for Twhere
T: ?Sized,
impl<T> BorrowMut<T> for Twhere
T: ?Sized,
Source§fn borrow_mut(&mut self) -> &mut T
fn borrow_mut(&mut self) -> &mut T
Source§impl<T> CloneToUninit for Twhere
T: Clone,
impl<T> CloneToUninit for Twhere
T: Clone,
impl<A, B, T> HttpServerConnExec<A, B> for Twhere
B: Body,
Source§impl<T> Instrument for T
impl<T> Instrument for T
Source§fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
Source§fn in_current_span(self) -> Instrumented<Self> ⓘ
fn in_current_span(self) -> Instrumented<Self> ⓘ
Source§impl<T> IntoEither for T
impl<T> IntoEither for T
Source§fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
self into a Left variant of Either<Self, Self>
if into_left is true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read moreSource§fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
self into a Left variant of Either<Self, Self>
if into_left(&self) returns true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read more