π‘οΈ Authentication¶
Authentication identifies the client making the request (login) and verifies that they have permission to access a specific resource (authorization by role/operation).
In HIX, there are two stacks that cover the two main scenarios:
| Stack | State | Typical use |
|---|---|---|
| Session + auth middleware | statefull (cookie + server storage) | Traditional web app, admin panel |
| JWT | stateless (signed token) | REST API, mobile, microservices |
This page covers the session-based stack β the pattern that Fenix uses. For the JWT stack, see JWT.
The pieces of the puzzle¶
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β HIX_MwSession loads/creates the session (HIXSID cookie) β
β β β
β βΌ β
β HIX_MwIsAuth reads session["_auth_user"] β
β - exists -> hData["user"] := hUser β
β - NIL -> 302 /login β
β β β
β βΌ β
β HIX_MwHasRole compares oCtx:cScope with user["roles"] β
β - allows -> continues β
β - denies -> 403 β
β β β
β βΌ β
β Controller hData["user"] available via URequest() β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
The three pieces are independent middlewares that combine into a single reusable chain.
Setup¶
Fenix convention - www/middlewares/config.json¶
{
"setup": {
"auth": {
"session_user_key": "_auth_user",
"roles_key": "roles",
"redirect_login": "/login",
"redirect_accept": "/main"
},
"session": {
"cookie": "FENIXSID",
"ttl": 3600,
"storage": "memory"
}
}
}
| Key | Purpose |
|---|---|
session_user_key |
Key within the session hash where the user is stored |
roles_key |
Key within the user hash that contains the roles |
redirect_login |
URL to redirect to if there is no active session (302) |
redirect_accept |
URL after successful login |
These values are read with UMwConfig( "auth", "session_user_key" ) from any controller or middleware.
Define the middleware groups¶
In Fenix, they are defined only once and reused across all routes:
// www/middlewares/myappauth.prg
FUNCTION MyAppAuth( oCtx )
LOCAL o := UBaseMiddleware():New( oCtx )
o:Add( UMiddleware():New( "HIX_MwSession" ) )
o:Add( UMiddleware():New( "HIX_MwIsAuth" ) )
RETURN o:Run()
FUNCTION MyAppAuthRole( oCtx )
LOCAL o := UBaseMiddleware():New( oCtx )
o:Add( UMiddleware():New( "HIX_MwSession" ) )
o:Add( UMiddleware():New( "HIX_MwIsAuth" ) )
o:Add( UMiddleware():New( "HIX_MwHasRole" ) )
RETURN o:Run()
And in config.json they are loaded on startup:
Apply to the routes¶
[
{ "name": "main",
"url": "/main", "method": "GET",
"action": "controllers/main.prg",
"middleware": "MyAppAuth" },
{ "name": "customer.show",
"url": "/customer/:id", "method": "GET",
"action": "controllers/masters/show@customer.prg",
"middleware": "MyAppAuthRole", "scope": "customers:show" },
{ "name": "customer.delete",
"url": "/customer/:id([0-9]+)/delete", "method": "POST",
"action": "controllers/masters/delete@customer.prg",
"middleware": "MyAppAuthRole", "scope": "customers:delete" }
]
The
"scope"field maps tooCtx:cScopeand is consumed byHIX_MwHasRole.
The user hash¶
HIX_MwIsAuth expects the session to contain a hash with this shape:
{ "id" => "1",
"name" => "Admin Demo",
"roles" => { "customers" => "show;edit;delete;create",
"sales" => "",
"purchases" => "" } }
Rules of the roles hash:
| Value | Meaning |
|---|---|
"" |
The user has full access to that role (any operation passes) |
"op1;op2;op3" |
Only those operations are allowed |
| (role absent) | The user does not have that role β 403 |
Scope syntax¶
oCtx:cScope is compared against the user's roles hash:
| Scope | Passes if... |
|---|---|
"" |
Always (no role check) |
"customers" |
The user has the customers role (with any ops) |
"customers:show" |
The user has customers with "" or with "show" in the list |
"customers:delete" |
The user has customers with "" or with "delete" in the list |
// Reading in HIX_MwHasRole
aScope := hb_ATokens( oCtx:cScope, ":" )
cRole := aScope[1] // "customers"
cOp := iif( Len( aScope ) > 1, aScope[2], "") // "show"
cOps := hb_HGetDef( hRoles, cRole, NIL )
Login - Fenix's auth.prg¶
// POST /auth middleware: MyAppLogin (Session + CsrfCheck)
FUNCTION Main()
LOCAL oVal, oSess, hUser
oVal := UValidatePost( { ;
"username" => { "required|min:3|max:30", "Username", "" }, ;
"password" => { "required|min:4", "Password", "" } ;
} )
IF ! oVal:Make()
UFlash( "login" ):Set( { ;
"error" => oVal:GetFirstError(), ;
"user" => oVal:Get( "username" ) } )
URedirect( "/login" )
RETURN
ENDIF
hUser := ModelUser( oVal:Get( "username" ), oVal:Get( "password" ) )
IF ValType( hUser ) == "H"
oSess := USession()
oSess:Set( UMwConfig( "auth", "session_user_key" ), hUser )
oSess:Save()
URedirect( UMwConfig( "auth", "redirect_accept" ) ) // -> /main
ELSE
UFlash( "login" ):Set( { ;
"error" => "Invalid username or password", ;
"user" => oVal:Get( "username" ) } )
URedirect( UMwConfig( "auth", "redirect_login" ) ) // -> /login
ENDIF
RETURN
#include '/models/modeluser.prg'
User model - modeluser.prg¶
FUNCTION ModelUser( cUser, cPass )
LOCAL hStore, hEntry
hStore := { ;
"demo" => { "id" => "1", "name" => "Admin Demo", "pass" => "1234", ;
"roles" => { "sales" => "", ;
"purchases" => "", ;
"customers" => "show;edit;delete;create" } ;
}, ;
"carles" => { "id" => "2", "name" => "Carles Aubia", "pass" => "1234", ;
"roles" => { "customers" => "show", ;
"purchases" => "" } } ;
}
hEntry := hb_HGetDef( hStore, Lower( cUser ), NIL )
IF hEntry == NIL .OR. ! ( hEntry["pass"] == cPass )
RETURN NIL
ENDIF
RETURN { "id" => hEntry["id"], ;
"name" => hEntry["name"], ;
"roles" => hEntry["roles"] }
In production,
ModelUserqueries a database and compares withhb_BCryptor similar. The shape of the returned hash does not change.
Logout - Fenix's logout.prg¶
Destroy() clears all session content and emits a cookie with Max-Age=0 so the browser deletes it.
Read the user from a controller¶
HIX_MwIsAuth leaves the hash in oCtx:hData["user"] and in oReq:hData["user"], so any protected controller can read it without touching the session directly:
PROCEDURE Main(...)
LOCAL oReq := URequest()
LOCAL hUser := hb_HGetDef( oReq:hData, "user", { "name" => "Unknown" } )
RETURN UView( "main.view.html", hUser["name"], hUser )
Check roles in code¶
IF UHasRole( "customers" ) // has the role (any op)
IF UHasRole( "customers", "delete" ) // has the role with that op
When to use which stack¶
| Use case | Stack |
|---|---|
| Web app with login form | β Session + IsAuth |
| Admin panel with fine-grained permissions | β Session + IsAuth + HasRole |
| REST API consumed by SPA with cookie | β Session |
| Stateless REST API / mobile / microservices | β Use JWT |
| Same endpoint accessible from web + API | Combine both middlewares |
Best practices¶
- Single source of truth for roles. The
roleshash travels in the sessionβif you change the user's permissions in the database, refresh the session or invalidate it. Destroy()on logout. Clear the entire session, not just the user key, to prevent session fixation.- CSRF mandatory on POST auth forms. Login redirects via
URedirectand leaves the session startedβwithout CSRF, an attacker can force a login. Combine with CSRF. - HTTPS in production. The cookie travels in every requestβwithout SSL an intermediary can steal it.
- Don't put the password in the session. Only
id,name,roles, and what's strictly necessary to authorize requests. - Granular roles with
""for admin. The empty value ("customers" => "") means "all ops"βuse it only for superusers.