Initial project import
This commit is contained in:
commit
23f2d29e32
11957 files changed
+1754577
No files matched your search
@@ -0,0 +1,346 @@
|
||||
# Sessions
|
||||
|
||||
A sessions module for web projects.
|
||||
|
||||
## Usage
|
||||
|
||||
The sessions module provides an implementation for [session stores](#custom-stores).
|
||||
The session store handles the saving, storing and retrieving of data. You can
|
||||
either use a store directly yourself, or you can use the `session.Sessions` struct
|
||||
which is easier to use since it also handles session verification and integrates nicely
|
||||
with veb.
|
||||
|
||||
If you want to use `session.Sessions` in your web app the session id's will be
|
||||
stored using cookies. The best way to get started is to follow the
|
||||
[getting started](#getting-started) section.
|
||||
|
||||
Otherwise have a look at the [advanced usage](#advanced-usage) section.
|
||||
|
||||
## Getting Started
|
||||
|
||||
The examples in this section use `x.veb`. See the [advanced usage](#advanced-usage) section
|
||||
for examples without `x.veb`.
|
||||
|
||||
To start using sessions in veb embed `sessions.CurrentSession` on the
|
||||
Context struct and add `sessions.Sessions` to the app struct. We must also pass the type
|
||||
of our session data.
|
||||
|
||||
For any further example code we will use the `User` struct.
|
||||
**Example:**
|
||||
|
||||
```v ignore
|
||||
import x.sessions
|
||||
import veb
|
||||
|
||||
pub struct User {
|
||||
pub mut:
|
||||
name string
|
||||
verified bool
|
||||
}
|
||||
|
||||
pub struct Context {
|
||||
veb.Context
|
||||
// By embedding the CurrentSession struct we can directly access the current session id
|
||||
// and any associated session data. Set the session data type to `User`
|
||||
sessions.CurrentSession[User]
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
pub mut:
|
||||
// this struct contains the store that holds all session data it also provides
|
||||
// an easy way to manage sessions in your veb app. Set the session data type to `User`
|
||||
sessions &sessions.Sessions[User]
|
||||
}
|
||||
```
|
||||
|
||||
Next we need to create the `&sessions.Sessions[User]` instance for our app. This
|
||||
struct provides functionality to easier manage sessions in a veb app.
|
||||
|
||||
### Session Stores
|
||||
|
||||
To create `sessions.Sessions` We must specify a "store" which handles the session data.
|
||||
Currently veb provides two options for storing session data:
|
||||
|
||||
1. The `MemoryStore[T]` stores session data in memory only using the `map` datatype.
|
||||
2. The `DBStore[T]` stores session data in a database by encoding the session data to JSON.
|
||||
It will create the table `DBStoreSessions` in your database, to store the session data.
|
||||
|
||||
It is possible to create your own session store, see [custom stores](#custom-stores).
|
||||
|
||||
### Starting the App
|
||||
|
||||
For this example we will use the memory store.
|
||||
|
||||
**Example:**
|
||||
|
||||
```v ignore
|
||||
fn main() {
|
||||
mut app := &App{
|
||||
store: sessions.MemoryStore[User]{}
|
||||
// use your own secret which will be used to verify session id's
|
||||
secret: 'my secret'.bytes()
|
||||
}
|
||||
|
||||
veb.run[App, Context](mut app, 8080)
|
||||
}
|
||||
```
|
||||
|
||||
### Middleware
|
||||
|
||||
The `sessions.veb2_middleware` module provides a middleware handler. This handler will execute
|
||||
before your own route handlers and will verify the current session and fetch any associated
|
||||
session data and load it into `sessions.CurrentSession`, which is embedded on the Context struct.
|
||||
|
||||
> **Note:**
|
||||
> It is recommended to use the middleware, so the sessions are always verfied
|
||||
> and loaded correctly.
|
||||
|
||||
**Example:**
|
||||
|
||||
```v ignore
|
||||
// add this import at the top of your file
|
||||
import x.sessions.veb2_middleware
|
||||
|
||||
pub struct App {
|
||||
// embed the Middleware struct from veb
|
||||
veb.Middleware[Context]
|
||||
pub mut:
|
||||
// this struct contains the store that holds all session data it also provides
|
||||
// an easy way to manage sessions in your veb app. Set the session data type to `User`
|
||||
sessions &sessions.Sessions[User]
|
||||
}
|
||||
|
||||
fn main() {
|
||||
mut app := &App{
|
||||
store: sessions.MemoryStore[User]{}
|
||||
// use your own secret which will be used to verify session id's
|
||||
secret: 'my secret'.bytes()
|
||||
}
|
||||
|
||||
// register the sessions middleware
|
||||
app.use(veb2_middleware.create[User, Context](mut app.sessions))
|
||||
|
||||
veb.run[App, Context](mut app, 8080)
|
||||
}
|
||||
```
|
||||
|
||||
You can now start using sessions with veb!
|
||||
|
||||
### Usage in endpoint handlers
|
||||
|
||||
#### Using Session Data
|
||||
|
||||
Because `sessions.CurrentSession` is embedded on the Context struct we can directly
|
||||
access any session data via `ctx.session_data`. This field is an option, it will be `none`
|
||||
if no data is set.
|
||||
|
||||
**Example:**
|
||||
|
||||
```v ignore
|
||||
pub fn (app &App) index(mut ctx Context) veb.Result {
|
||||
// check if a user is logged in
|
||||
if user := ctx.session_data {
|
||||
return ctx.text('Welcome ${user.name}! Verification status: ${user.verified}')
|
||||
} else {
|
||||
// user is not logged in
|
||||
return ctx.text('You are not logged in :(')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Saving / updating session data
|
||||
|
||||
You can use the `save` method to update and save any session data.
|
||||
|
||||
When the user logs in, the `save` method is called and a new session id is generated
|
||||
and set as cookie. Assuming there wasn't already a session going on. If you want to
|
||||
be sure that a new session id is generated when you save data, you can use the `resave`
|
||||
method. This method will save the data and *always* set a new session id.
|
||||
|
||||
**Example:**
|
||||
|
||||
```v ignore
|
||||
pub fn (mut app App) login(mut ctx Context) veb.Result {
|
||||
// set a session id cookie and save data for the new user
|
||||
app.sessions.save(mut ctx, User{
|
||||
name: '[no name provided]'
|
||||
}) or { return ctx.server_error('could not save session data, please try again') }
|
||||
return ctx.text('You are now logged in!')
|
||||
}
|
||||
```
|
||||
|
||||
The following endpoint checks if a session exists, if it doesn't inform the
|
||||
user that they need to login.
|
||||
|
||||
If a session does exists the users name is updated to the `name` query parameter,
|
||||
you can use this route via `http://localhost:8080/save?name=myname`. And if the
|
||||
query parameter is not passed an error 400 (bad request) is returned.
|
||||
|
||||
**Example:**
|
||||
|
||||
```v ignore
|
||||
pub fn (mut app App) save(mut ctx Context) veb.Result {
|
||||
// check if there is a session
|
||||
app.sessions.get(ctx) or { return ctx.request_error('You are not logged in :(') }
|
||||
|
||||
if name := ctx.query['name'] {
|
||||
// update the current user
|
||||
app.sessions.save(mut ctx, User{
|
||||
name: name
|
||||
}) or { return ctx.server_error('could not save session data, please try again') }
|
||||
return ctx.redirect('/', typ: .see_other)
|
||||
} else {
|
||||
// send HTTP 400 error
|
||||
return ctx.request_error('query parameter "name" must be present!')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Destroying data / logging out
|
||||
|
||||
If a user logs out you can use the `logout` method to destroy the session data and
|
||||
clear the session id cookie. If you only want to destroy the session data use the `destroy`
|
||||
method.
|
||||
|
||||
**Example:**
|
||||
|
||||
```v ignore
|
||||
pub fn (mut app App) logout(mut ctx Context) veb.Result {
|
||||
app.sessions.logout(mut ctx) or { return ctx.server_error('could not logout, please try again') }
|
||||
return ctx.text('You are now logged out!')
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Change the `cookie_options` field to modify how the session cookie is stored.
|
||||
|
||||
**Example:**
|
||||
|
||||
```v ignore
|
||||
mut app := &App{
|
||||
sessions: &sessions.Sessions[User]{
|
||||
// ...
|
||||
cookie_options: sessions.CookieOptions{
|
||||
// cookie can only be stored on an HTTPS site.
|
||||
secure: true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Mag-age
|
||||
|
||||
By default the expiration date of a session is 30 days. You can change this by
|
||||
setting the `max_age` field. If `max_age = 0`, then session expiration times are not
|
||||
checked and sessions will be stored forever until they are destroyed.
|
||||
|
||||
#### Pre-sessions
|
||||
|
||||
By default a session cookie is only generated when you call `save`, or `resave`.
|
||||
By setting `save_uninitialized` to `true` a session cookie will always be set,
|
||||
even if there is no data for the session yet. This is useful when you need session
|
||||
data to be always available.
|
||||
|
||||
Or, for example, you could use pre-sessions to mitigate login-csrf,
|
||||
since you can bind a csrf-token to the "pre-session" id. Then when the user logs
|
||||
in, you can set a new session id with `resave`..
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
If you want to store session id's in another manner than cookies, or if you want
|
||||
to use this sessions module outside of veb, the easiest way is to create an
|
||||
instance of a `Store` and directly interact with it.
|
||||
|
||||
First we create an instance of the `MemoryStore` and pass the user struct as data type.
|
||||
**Example:**
|
||||
|
||||
```v
|
||||
import x.sessions
|
||||
|
||||
const secret = 'my secret'.bytes()
|
||||
|
||||
pub struct User {
|
||||
pub mut:
|
||||
name string
|
||||
verified bool
|
||||
}
|
||||
|
||||
fn main() {
|
||||
mut store := sessions.MemoryStore[User]{}
|
||||
|
||||
user := User{
|
||||
name: 'vaesel'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Generating and validating session id's
|
||||
|
||||
The session module provides a function for generating a new signed session id
|
||||
and for verifying a signed session id. You can ofcourse generate your own session id's.
|
||||
|
||||
**Example:**
|
||||
|
||||
```v ignore
|
||||
// fn main
|
||||
// generate a new session id and sign it
|
||||
session_id, signed_session_id := sessions.new_session_id(secret)
|
||||
// save session data to our store
|
||||
store.set(session_id, user)!
|
||||
|
||||
// get a normal session id from the signed version and verify it
|
||||
verified_session_id, valid := sessions.verify_session_id(signed_session_id, secret)
|
||||
assert verified_session_id == session_id && valid == true
|
||||
```
|
||||
|
||||
We can retrieve the saved user and verify that the data we saved can be retrieved
|
||||
from the verified session id.
|
||||
|
||||
**Example:**
|
||||
|
||||
```v ignore
|
||||
// fn main
|
||||
// pass `max_age = 0` to ignore the expiration time.
|
||||
if saved_user := store.get(verified_session_id, 0) {
|
||||
assert user == saved_user
|
||||
println('Retrieved a valid user! ${saved_user}')
|
||||
} else {
|
||||
println(':(')
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Stores
|
||||
|
||||
You can easily create your own custom store in order to control how session data is
|
||||
stored and retrieved. Each session store needs to implement the `Store[T]` interface.
|
||||
|
||||
```v ignore
|
||||
pub interface Store[T] {
|
||||
mut:
|
||||
// get the current session data if the id exists and if it's not expired.
|
||||
// If the session is expired, any associated data should be destroyed.
|
||||
// If `max_age=0` the store will not check for expiration of the session.
|
||||
get(sid string, max_age time.Duration) !T
|
||||
// destroy session data for `sid`
|
||||
destroy(sid string) !
|
||||
// set session data for `sid`
|
||||
set(sid string, val T) !
|
||||
}
|
||||
|
||||
// get data from all sessions, optional to implement
|
||||
pub fn (mut s Store) all[T]() ![]T {
|
||||
return []T{}
|
||||
}
|
||||
|
||||
// clear all session data, optional to implement
|
||||
pub fn (mut s Store) clear[T]() ! {}
|
||||
```
|
||||
|
||||
Only the `get`, `destroy` and `set` methods are required to implement.
|
||||
|
||||
### Session Expire time
|
||||
|
||||
The `max_age` argument in `get` can be used to check whether the session is still valid.
|
||||
The database and memory store both check the expiration time from the time the session data
|
||||
first inserted. But if `max_age = 0`, the stores will not check for expiration time.
|
||||
@@ -0,0 +1,100 @@
|
||||
module sessions
|
||||
|
||||
import json
|
||||
import orm
|
||||
import time
|
||||
|
||||
// DBStoreSessions is the table that is created in your database and represents a session data record.
|
||||
pub struct DBStoreSessions {
|
||||
pub mut:
|
||||
session_id string @[primary]
|
||||
created_at time.Time
|
||||
data string
|
||||
}
|
||||
|
||||
// DBStore stores sessions in a database.
|
||||
@[noinit]
|
||||
pub struct DBStore[T] {
|
||||
pub mut:
|
||||
db orm.Connection @[required]
|
||||
}
|
||||
|
||||
// create a new Database store with a connection to a database.
|
||||
pub fn DBStore.create[T](db orm.Connection) !DBStore[T] {
|
||||
sql db {
|
||||
create table DBStoreSessions
|
||||
}!
|
||||
|
||||
return DBStore[T]{
|
||||
db: db
|
||||
}
|
||||
}
|
||||
|
||||
// all gets the data from all sessions.
|
||||
pub fn (mut store DBStore[T]) all() ![]T {
|
||||
rows := sql store.db {
|
||||
select from DBStoreSessions
|
||||
}!
|
||||
|
||||
// decode should never fail
|
||||
return rows.map(json.decode(T, it.data)!)
|
||||
}
|
||||
|
||||
// get session for session id `sid`. The session can be `max_age` old.
|
||||
// `max_age` will be ignored when set to `0`
|
||||
pub fn (mut store DBStore[T]) get(sid string, max_age time.Duration) !T {
|
||||
rows := sql store.db {
|
||||
select from DBStoreSessions where session_id == sid
|
||||
}!
|
||||
|
||||
if rows.len == 1 {
|
||||
record := rows[0]
|
||||
// session is expired
|
||||
if max_age != 0 && record.created_at.add(max_age) < time.now() {
|
||||
store.destroy(sid)!
|
||||
return error('session is expired')
|
||||
}
|
||||
|
||||
return json.decode(T, record.data)!
|
||||
} else {
|
||||
return error('session does not exist')
|
||||
}
|
||||
}
|
||||
|
||||
// destroy data for session id `sid`.
|
||||
pub fn (mut store DBStore[T]) destroy(sid string) ! {
|
||||
sql store.db {
|
||||
delete from DBStoreSessions where session_id == sid
|
||||
}!
|
||||
}
|
||||
|
||||
// clear all sessions.
|
||||
pub fn (mut store DBStore[T]) clear() ! {
|
||||
sql store.db {
|
||||
delete from DBStoreSessions where session_id != ''
|
||||
}!
|
||||
}
|
||||
|
||||
// set session data for session id `sid`.
|
||||
pub fn (mut store DBStore[T]) set(sid string, val T) ! {
|
||||
count := sql store.db {
|
||||
select count from DBStoreSessions where session_id == sid
|
||||
}!
|
||||
|
||||
if count == 1 {
|
||||
stringified := json.encode(val)
|
||||
sql store.db {
|
||||
update DBStoreSessions set data = stringified where session_id == sid
|
||||
}!
|
||||
} else {
|
||||
record := DBStoreSessions{
|
||||
session_id: sid
|
||||
created_at: time.now()
|
||||
data: json.encode(val)
|
||||
}
|
||||
|
||||
sql store.db {
|
||||
insert record into DBStoreSessions
|
||||
}!
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
module sessions
|
||||
|
||||
import time
|
||||
|
||||
pub struct MemoryStoreSessions[T] {
|
||||
pub mut:
|
||||
created_at time.Time
|
||||
data T
|
||||
}
|
||||
|
||||
// MemoryStore stores sessions in a `map` in memory only.
|
||||
pub struct MemoryStore[T] {
|
||||
pub mut:
|
||||
data map[string]MemoryStoreSessions[T]
|
||||
}
|
||||
|
||||
// get data from all sessions.
|
||||
pub fn (mut store MemoryStore[T]) all() ![]T {
|
||||
return store.data.values().map(it.data)
|
||||
}
|
||||
|
||||
// get session for session id `sid`. The session can be `max_age` old.
|
||||
// `max_age` will be ignored when set to `0`
|
||||
pub fn (mut store MemoryStore[T]) get(sid string, max_age time.Duration) !T {
|
||||
if record := store.data[sid] {
|
||||
// session is expired
|
||||
if max_age != 0 && record.created_at.add(max_age) < time.now() {
|
||||
store.destroy(sid)!
|
||||
return error('session is expired')
|
||||
}
|
||||
|
||||
return record.data
|
||||
} else {
|
||||
return error('session does not exist')
|
||||
}
|
||||
}
|
||||
|
||||
// destroy data for session id `sid`.
|
||||
pub fn (mut store MemoryStore[T]) destroy(sid string) ! {
|
||||
store.data.delete(sid)
|
||||
}
|
||||
|
||||
// clear all sessions.
|
||||
pub fn (mut store MemoryStore[T]) clear() ! {
|
||||
store.data.clear()
|
||||
}
|
||||
|
||||
// set session data for session id `sid`.
|
||||
pub fn (mut store MemoryStore[T]) set(sid string, val T) ! {
|
||||
if sid in store.data {
|
||||
store.data[sid].data = val
|
||||
} else {
|
||||
store.data[sid] = MemoryStoreSessions[T]{
|
||||
created_at: time.now()
|
||||
data: val
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
module sessions
|
||||
|
||||
import crypto.sha256
|
||||
import crypto.hmac
|
||||
import encoding.base64
|
||||
import net.http
|
||||
import rand
|
||||
import time
|
||||
|
||||
const session_id_length = 32
|
||||
|
||||
// new_session_id creates and returns a random session id and its signed version.
|
||||
// You can directly use the signed version as a cookie value
|
||||
pub fn new_session_id(secret []u8) (string, string) {
|
||||
sid := rand.hex(session_id_length)
|
||||
|
||||
hashed := hmac.new(secret, sid.bytes(), sha256.sum, sha256.block_size)
|
||||
|
||||
// separate session id and hmac with a `.`
|
||||
return sid, '${sid}.${base64.url_encode(hashed)}'
|
||||
}
|
||||
|
||||
// verify_session_id verifies the signed session id with `secret`.
|
||||
// This function returns the session id and if the session id is valid.
|
||||
pub fn verify_session_id(raw_sid string, secret []u8) (string, bool) {
|
||||
parts := raw_sid.split('.')
|
||||
if parts.len != 2 {
|
||||
return '', false
|
||||
}
|
||||
|
||||
sid := parts[0]
|
||||
actual_hmac := base64.url_decode(parts[1])
|
||||
|
||||
new_hmac := hmac.new(secret, sid.bytes(), sha256.sum, sha256.block_size)
|
||||
// use `hmac.equal` to prevent leaking timing information
|
||||
return sid, hmac.equal(actual_hmac, new_hmac)
|
||||
}
|
||||
|
||||
// CurrentSession contains the session data during a request.
|
||||
// If you use veb you could embed it on your Context struct to have easy access to the session id and data.
|
||||
// Usage example:
|
||||
// ```v
|
||||
// struct Context {
|
||||
// veb.Context
|
||||
// sessions.CurrentSessions[User]
|
||||
// }
|
||||
// ```
|
||||
pub struct CurrentSession[T] {
|
||||
pub mut:
|
||||
session_id string
|
||||
session_data ?T
|
||||
}
|
||||
|
||||
// CookieOptions contains the default settings for the cookie created in the `Sessions` struct.
|
||||
pub struct CookieOptions {
|
||||
pub:
|
||||
cookie_name string = 'sid'
|
||||
domain string
|
||||
http_only bool = true
|
||||
path string = '/'
|
||||
same_site http.SameSite = .same_site_strict_mode
|
||||
secure bool
|
||||
}
|
||||
|
||||
// Sessions can be used to easily integrate sessions with veb.
|
||||
// This struct contains the store that holds all session data it also provides
|
||||
// an easy way to manage sessions in your veb app.
|
||||
// Usage example:
|
||||
// ```v
|
||||
// pub struct App {
|
||||
// pub mut:
|
||||
// sessions &sessions.Sessions[User]
|
||||
// }
|
||||
// ```
|
||||
@[heap]
|
||||
pub struct Sessions[T] {
|
||||
pub:
|
||||
secret []u8 @[required]
|
||||
cookie_options CookieOptions
|
||||
// max age of session data and id, default is 30 days
|
||||
max_age time.Duration = time.hour * 24 * 30
|
||||
// set to true if you want to create a session if there isn't any data stored yet.
|
||||
// Also called pre-sessions
|
||||
save_uninitialized bool
|
||||
pub mut:
|
||||
store Store[T] @[required]
|
||||
}
|
||||
|
||||
// set_session_id generates a new session id and set a Set-Cookie header on the response.
|
||||
pub fn (mut s Sessions[T]) set_session_id[X](mut ctx X) string {
|
||||
sid, signed := new_session_id(s.secret)
|
||||
ctx.CurrentSession.session_id = sid
|
||||
|
||||
ctx.set_cookie(http.Cookie{
|
||||
value: signed
|
||||
max_age: int(s.max_age / time.second)
|
||||
domain: s.cookie_options.domain
|
||||
http_only: s.cookie_options.http_only
|
||||
name: s.cookie_options.cookie_name
|
||||
path: s.cookie_options.path
|
||||
same_site: s.cookie_options.same_site
|
||||
secure: s.cookie_options.secure
|
||||
})
|
||||
// indicate that the response should not be cached: we don't want the session id cookie
|
||||
// to be cached by the browser, or any other agent
|
||||
// https://datatracker.ietf.org/doc/html/rfc7234#section-5.2.1.4
|
||||
ctx.res.header.add(.cache_control, 'no-cache="Set-Cookie"')
|
||||
|
||||
return sid
|
||||
}
|
||||
|
||||
// validate_session validates the current session, returns the session id and the validation status.
|
||||
pub fn (mut s Sessions[T]) validate_session[X](ctx X) (string, bool) {
|
||||
cookie := ctx.get_cookie(s.cookie_options.cookie_name) or { return '', false }
|
||||
|
||||
return verify_session_id(cookie, s.secret)
|
||||
}
|
||||
|
||||
// get the data associated with the current session, if it exists.
|
||||
pub fn (mut s Sessions[T]) get[X](ctx X) !T {
|
||||
sid := s.get_session_id(ctx) or { return error('cannot find session id') }
|
||||
return s.store.get(sid, s.max_age)!
|
||||
}
|
||||
|
||||
// destroy the data for the current session.
|
||||
pub fn (mut s Sessions[T]) destroy[X](mut ctx X) ! {
|
||||
if sid := s.get_session_id(ctx) {
|
||||
s.store.destroy(sid)!
|
||||
ctx.session_data = none
|
||||
}
|
||||
}
|
||||
|
||||
// logout destroys the data for the current session and removes the session id Cookie.
|
||||
pub fn (mut s Sessions[T]) logout[X](mut ctx X) ! {
|
||||
s.destroy(mut ctx)!
|
||||
ctx.set_cookie(http.Cookie{
|
||||
name: s.cookie_options.cookie_name
|
||||
value: ''
|
||||
expires: time.unix(0)
|
||||
})
|
||||
}
|
||||
|
||||
// save `data` for the current session.
|
||||
pub fn (mut s Sessions[T]) save[X](mut ctx X, data T) ! {
|
||||
if sid := s.get_session_id(ctx) {
|
||||
s.store.set(sid, data)!
|
||||
ctx.CurrentSession.session_data = data
|
||||
} else {
|
||||
if s.save_uninitialized == false {
|
||||
// no valid session id, but the user only wants to create a session
|
||||
// when data is saved. So we create the session here
|
||||
sid := s.set_session_id(mut ctx)
|
||||
s.store.set(sid, data)!
|
||||
ctx.CurrentSession.session_data = data
|
||||
}
|
||||
eprintln('[veb.sessions] error: trying to save data without a valid session!')
|
||||
}
|
||||
}
|
||||
|
||||
// resave saves `data` for the current session and reset the session id.
|
||||
// You should use this function when the authentication or authorization status changes
|
||||
// e.g. when a user signs in or switches between accounts/permissions.
|
||||
// This function also destroys the data associated to the old session id.
|
||||
pub fn (mut s Sessions[T]) resave[X](mut ctx X, data T) ! {
|
||||
if sid := s.get_session_id(ctx) {
|
||||
s.store.destroy(sid)!
|
||||
}
|
||||
|
||||
s.save(mut ctx, data)
|
||||
}
|
||||
|
||||
// get_session_id retrieves the current session id, if it is set.
|
||||
// The HMAC signature is verified when extracting from cookies.
|
||||
pub fn (s &Sessions[T]) get_session_id[X](ctx X) ?string {
|
||||
// first check session id from `ctx`
|
||||
sid_from_ctx := ctx.CurrentSession.session_id
|
||||
if sid_from_ctx != '' {
|
||||
return sid_from_ctx
|
||||
} else if cookie := ctx.get_cookie(s.cookie_options.cookie_name) {
|
||||
// check request headers for the session_id cookie and verify HMAC signature
|
||||
sid, valid := verify_session_id(cookie, s.secret)
|
||||
if valid {
|
||||
return sid
|
||||
}
|
||||
return none
|
||||
} else {
|
||||
// check the Set-Cookie headers on the response for a session id
|
||||
for cookie in ctx.res.cookies() {
|
||||
if cookie.name == s.cookie_options.cookie_name {
|
||||
return cookie.value
|
||||
}
|
||||
}
|
||||
|
||||
// No session id is set
|
||||
return none
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
module sessions
|
||||
|
||||
import time
|
||||
|
||||
pub interface Store[T] {
|
||||
mut:
|
||||
// get the current session data if the id exists and if it's not expired
|
||||
get(sid string, max_age time.Duration) !T
|
||||
// destroy session data for `sid`
|
||||
destroy(sid string) !
|
||||
// set session data for `val`
|
||||
set(sid string, val T) !
|
||||
}
|
||||
|
||||
// get data from all sessions, optional to implement.
|
||||
pub fn (mut s Store[T]) all[T]() ![]T {
|
||||
return []T{}
|
||||
}
|
||||
|
||||
// clear all session data, optional to implement.
|
||||
pub fn (mut s Store[T]) clear[T]() ! {}
|
||||
@@ -0,0 +1,99 @@
|
||||
// vtest retry: 3
|
||||
import db.sqlite
|
||||
import x.json2 as json
|
||||
import os
|
||||
import time
|
||||
import x.sessions
|
||||
|
||||
const max_age = time.second
|
||||
const db_path = os.join_path(os.vtmp_dir(), 'x_sessions_db_test.db')
|
||||
|
||||
fn testsuite_begin() {
|
||||
os.rm(db_path) or {}
|
||||
}
|
||||
|
||||
fn testsuite_end() {
|
||||
os.rm(db_path) or {}
|
||||
}
|
||||
|
||||
pub struct User {
|
||||
name string
|
||||
age int
|
||||
}
|
||||
|
||||
const default_user = User{
|
||||
name: 'john'
|
||||
age: 99
|
||||
}
|
||||
const default_user_encoded = json.encode(default_user)
|
||||
|
||||
fn test_store_set() {
|
||||
mut db := get_connection()!
|
||||
defer {
|
||||
db.close() or {}
|
||||
}
|
||||
mut store := sessions.DBStore.create[User](db)!
|
||||
store.set('a', default_user)!
|
||||
|
||||
mut rows := sql db {
|
||||
select from sessions.DBStoreSessions
|
||||
}!
|
||||
assert rows.len == 1
|
||||
|
||||
// check if created at time is not empty
|
||||
assert rows[0].created_at != time.Time{}
|
||||
assert rows[0].data == default_user_encoded
|
||||
|
||||
first_created := rows[0].created_at
|
||||
store.set('a', User{ age: 99 })!
|
||||
|
||||
rows = sql db {
|
||||
select from sessions.DBStoreSessions
|
||||
}!
|
||||
assert rows.len == 1
|
||||
assert rows[0].created_at == first_created
|
||||
assert rows[0].data == json.encode(User{ age: 99 })
|
||||
}
|
||||
|
||||
fn test_store_get() {
|
||||
mut db := get_connection()!
|
||||
defer {
|
||||
db.close() or {}
|
||||
}
|
||||
mut store := sessions.DBStore.create[User](db)!
|
||||
store.set('b', default_user)!
|
||||
|
||||
if data := store.get('b', max_age) {
|
||||
assert data == default_user
|
||||
} else {
|
||||
$if !windows {
|
||||
assert false, 'session data should not be none'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn test_store_session_expired() {
|
||||
mut db := get_connection()!
|
||||
defer {
|
||||
db.close() or {}
|
||||
}
|
||||
mut store := sessions.DBStore.create[User](db)!
|
||||
store.set('c', default_user)!
|
||||
|
||||
time.sleep(2 * max_age)
|
||||
|
||||
if data := store.get('c', max_age) {
|
||||
assert false, 'session should be expired!'
|
||||
} else {
|
||||
assert err.msg() == 'session is expired'
|
||||
}
|
||||
// verify that data is deleted
|
||||
rows := sql db {
|
||||
select from sessions.DBStoreSessions where session_id == 'c'
|
||||
}!
|
||||
assert rows.len == 0
|
||||
}
|
||||
|
||||
fn get_connection() !sqlite.DB {
|
||||
return sqlite.connect(db_path)!
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import time
|
||||
import x.sessions
|
||||
|
||||
const max_age = time.second
|
||||
|
||||
pub struct User {
|
||||
name string
|
||||
age int
|
||||
}
|
||||
|
||||
const default_user = User{
|
||||
name: 'john'
|
||||
age: 99
|
||||
}
|
||||
|
||||
fn test_store_set() {
|
||||
mut store := sessions.MemoryStore[User]{}
|
||||
store.set('a', default_user)!
|
||||
|
||||
// check if created at time is not empty
|
||||
assert store.data['a'].created_at != time.Time{}
|
||||
assert store.data['a'].data == default_user
|
||||
|
||||
first_created := store.data['a'].created_at
|
||||
store.set('a', User{ age: 99 })!
|
||||
|
||||
assert store.data['a'].created_at == first_created
|
||||
assert store.data['a'].data.age == 99
|
||||
}
|
||||
|
||||
fn test_store_get() {
|
||||
mut store := sessions.MemoryStore[User]{}
|
||||
store.set('a', default_user)!
|
||||
|
||||
if data := store.get('a', max_age) {
|
||||
assert data == default_user
|
||||
} else {
|
||||
assert false, 'session data should not be none'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_store_session_expired() {
|
||||
mut store := sessions.MemoryStore[User]{}
|
||||
store.set('a', default_user)!
|
||||
|
||||
time.sleep(2 * max_age)
|
||||
|
||||
if data := store.get('a', max_age) {
|
||||
assert false, 'session should be expired!'
|
||||
} else {
|
||||
assert err.msg() == 'session is expired'
|
||||
}
|
||||
assert store.data.len == 0
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// vtest retry: 2
|
||||
import net.http
|
||||
import time
|
||||
import x.sessions
|
||||
import veb
|
||||
import x.sessions.veb_middleware
|
||||
|
||||
const port = 13010
|
||||
const localserver = 'http://127.0.0.1:${port}'
|
||||
const exit_after = time.second * 10
|
||||
const cookie_name = 'SESSION_ID'
|
||||
const max_age = time.second * 2
|
||||
|
||||
pub struct User {
|
||||
pub mut:
|
||||
name string
|
||||
age int
|
||||
verified bool
|
||||
}
|
||||
|
||||
const default_user = User{
|
||||
name: 'casper'
|
||||
age: 21
|
||||
verified: false
|
||||
}
|
||||
|
||||
pub struct Context {
|
||||
veb.Context
|
||||
sessions.CurrentSession[User]
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
veb.Middleware[Context]
|
||||
pub mut:
|
||||
sessions &sessions.Sessions[User]
|
||||
started chan bool
|
||||
}
|
||||
|
||||
pub fn (mut app App) before_accept_loop() {
|
||||
app.started <- true
|
||||
}
|
||||
|
||||
pub fn (app &App) session_data(mut ctx Context) veb.Result {
|
||||
return ctx.text('${ctx.session_data}')
|
||||
}
|
||||
|
||||
pub fn (app &App) protected(mut ctx Context) veb.Result {
|
||||
if user := ctx.session_data {
|
||||
return ctx.json(user)
|
||||
} else {
|
||||
ctx.res.set_status(.unauthorized)
|
||||
return ctx.text('No session saved!')
|
||||
}
|
||||
}
|
||||
|
||||
pub fn (mut app App) save_session(mut ctx Context) veb.Result {
|
||||
app.sessions.save(mut ctx, default_user) or { return ctx.server_error(err.msg()) }
|
||||
return ctx.ok('')
|
||||
}
|
||||
|
||||
pub fn (mut app App) update_session(mut ctx Context) veb.Result {
|
||||
if mut user := ctx.session_data {
|
||||
user.age++
|
||||
app.sessions.save(mut ctx, user) or { return ctx.server_error(err.msg()) }
|
||||
// sessions module should also update the context
|
||||
if new_user := ctx.session_data {
|
||||
assert new_user == user, 'session data is not updated on the context'
|
||||
} else {
|
||||
assert false, 'session data should not be none'
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.ok('')
|
||||
}
|
||||
|
||||
pub fn (mut app App) destroy_session(mut ctx Context) veb.Result {
|
||||
app.sessions.destroy(mut ctx) or { return ctx.server_error(err.msg()) }
|
||||
// sessions module should also update the context
|
||||
assert ctx.session_data == none
|
||||
|
||||
return ctx.ok('')
|
||||
}
|
||||
|
||||
fn testsuite_begin() {
|
||||
spawn fn () {
|
||||
time.sleep(exit_after)
|
||||
assert false, 'timeout reached!'
|
||||
exit(1)
|
||||
}()
|
||||
|
||||
mut app := &App{
|
||||
sessions: &sessions.Sessions[User]{
|
||||
store: sessions.MemoryStore[User]{}
|
||||
secret: 'secret'.bytes()
|
||||
cookie_options: sessions.CookieOptions{
|
||||
cookie_name: cookie_name
|
||||
}
|
||||
max_age: max_age
|
||||
}
|
||||
}
|
||||
|
||||
app.use(veb_middleware.create[User, Context](mut app.sessions))
|
||||
|
||||
spawn veb.run_at[App, Context](mut app, port: port, family: .ip, timeout_in_seconds: 2)
|
||||
// app startup time
|
||||
_ := <-app.started
|
||||
wait_for_server()!
|
||||
}
|
||||
|
||||
fn test_empty_session() {
|
||||
x := http.get('${localserver}/session_data')!
|
||||
|
||||
assert x.body == 'Option(none)'
|
||||
}
|
||||
|
||||
fn test_protected_without_session() {
|
||||
x := http.get('${localserver}/protected')!
|
||||
|
||||
assert x.status() == .unauthorized
|
||||
}
|
||||
|
||||
fn test_save_session() {
|
||||
sid := get_session_id()!
|
||||
x := make_request_with_session_id(.get, '/session_data', sid)!
|
||||
|
||||
// cast to `?User` since, session_data is of type `?T`
|
||||
assert x.body == '${?User(default_user)}'
|
||||
}
|
||||
|
||||
fn test_update_session() {
|
||||
sid := get_session_id()!
|
||||
|
||||
mut x := make_request_with_session_id(.get, '/update_session', sid)!
|
||||
x = make_request_with_session_id(.get, '/session_data', sid)!
|
||||
|
||||
mut updated_user := default_user
|
||||
updated_user.age++
|
||||
assert x.body == '${?User(updated_user)}'
|
||||
}
|
||||
|
||||
fn test_destroy_session() {
|
||||
sid := get_session_id()!
|
||||
mut x := make_request_with_session_id(.get, '/session_data', sid)!
|
||||
assert x.body != 'Option(none)'
|
||||
|
||||
x = make_request_with_session_id(.get, '/destroy_session', sid)!
|
||||
assert x.status() == .ok
|
||||
|
||||
x = make_request_with_session_id(.get, '/session_data', sid)!
|
||||
assert x.body == 'Option(none)'
|
||||
}
|
||||
|
||||
fn test_session_expired() {
|
||||
sid := get_session_id()!
|
||||
mut x := make_request_with_session_id(.get, '/session_data', sid)!
|
||||
assert x.body != 'Option(none)'
|
||||
|
||||
time.sleep(max_age)
|
||||
|
||||
x = make_request_with_session_id(.get, '/session_data', sid)!
|
||||
assert x.body == 'Option(none)'
|
||||
}
|
||||
|
||||
// Utility
|
||||
|
||||
fn get_session_id() !string {
|
||||
x := http.get('${localserver}/save_session')!
|
||||
|
||||
return x.cookies()[0].value
|
||||
}
|
||||
|
||||
fn make_request_with_session_id(method http.Method, path string, sid string) !http.Response {
|
||||
return http.fetch(http.FetchConfig{
|
||||
url: '${localserver}${path}'
|
||||
method: method
|
||||
cookies: {
|
||||
cookie_name: sid
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn wait_for_server() ! {
|
||||
for _ in 0 .. 40 {
|
||||
response := http.get('${localserver}/session_data') or {
|
||||
time.sleep(50 * time.millisecond)
|
||||
continue
|
||||
}
|
||||
if response.status() == .ok {
|
||||
return
|
||||
}
|
||||
time.sleep(50 * time.millisecond)
|
||||
}
|
||||
return error('session test server did not start in time')
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import time
|
||||
import x.sessions
|
||||
|
||||
const max_age = time.second
|
||||
const secret = 'session_test'.bytes()
|
||||
|
||||
pub struct User {
|
||||
name string
|
||||
age int
|
||||
}
|
||||
|
||||
const default_user = User{
|
||||
name: 'john'
|
||||
age: 99
|
||||
}
|
||||
|
||||
fn test_session_id() {
|
||||
unverified_sid, sid_with_hmac := sessions.new_session_id(secret)
|
||||
verified_sid, valid := sessions.verify_session_id(sid_with_hmac, secret)
|
||||
|
||||
assert unverified_sid == verified_sid
|
||||
assert valid
|
||||
}
|
||||
|
||||
fn test_forged_signature_rejected() {
|
||||
// Create a valid session ID
|
||||
sid, _ := sessions.new_session_id(secret)
|
||||
|
||||
// Forge a cookie with valid session ID but invalid signature
|
||||
forged_cookie := '${sid}.INVALID_SIGNATURE'
|
||||
verified_sid, valid := sessions.verify_session_id(forged_cookie, secret)
|
||||
|
||||
// Forged signature must be rejected
|
||||
assert !valid
|
||||
assert verified_sid == sid
|
||||
}
|
||||
|
||||
fn test_wrong_secret_rejected() {
|
||||
// Create a session with one secret
|
||||
_, signed_cookie := sessions.new_session_id(secret)
|
||||
|
||||
// Try to verify with a different secret
|
||||
wrong_secret := 'wrong_secret'.bytes()
|
||||
_, valid := sessions.verify_session_id(signed_cookie, wrong_secret)
|
||||
|
||||
// Must be rejected when using wrong secret
|
||||
assert !valid
|
||||
}
|
||||
|
||||
fn test_malformed_cookie_rejected() {
|
||||
// Cookie without signature separator
|
||||
_, valid1 := sessions.verify_session_id('just_a_session_id', secret)
|
||||
assert !valid1
|
||||
|
||||
// Empty cookie
|
||||
_, valid2 := sessions.verify_session_id('', secret)
|
||||
assert !valid2
|
||||
|
||||
// Cookie with empty parts
|
||||
_, valid3 := sessions.verify_session_id('.', secret)
|
||||
assert !valid3
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
module veb_middleware
|
||||
|
||||
import x.sessions
|
||||
import veb
|
||||
|
||||
// create can be used to add session middleware to your veb app to ensure
|
||||
// a valid session always exists. If a valid session exists the session data will
|
||||
// be loaded into `session_data`, else a new session id will be generated.
|
||||
// You have to pass the Context type as the generic type.
|
||||
pub fn create[T, X](mut s sessions.Sessions[T]) veb.MiddlewareOptions[X] {
|
||||
return veb.MiddlewareOptions[X]{
|
||||
handler: fn [mut s] [T, X](mut ctx X) bool {
|
||||
// a session id is retrieved from the client, so it must be considered
|
||||
// untrusted and has to be verified on every request
|
||||
sid, valid := s.validate_session(ctx)
|
||||
|
||||
if !valid {
|
||||
if s.save_uninitialized {
|
||||
// invalid session id, so create a new one
|
||||
s.set_session_id(mut ctx)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
ctx.CurrentSession.session_id = sid
|
||||
if data := s.store.get(sid, s.max_age) {
|
||||
ctx.CurrentSession.session_data = data
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user