Overview
oForum uses session-based authentication with database-stored tokens and bcrypt password hashing. No JWTs, no third-party auth — just secure, simple sessions.
Password Hashing bcrypt with default cost for secure password storage
Session Tokens Cryptographically random tokens stored in database
HttpOnly Cookies Prevents JavaScript access to session tokens
Role-Based Access Admin permissions derived from roles table
Password Hashing
oForum uses bcrypt for password hashing:
internal/auth/auth.go:12-20
func HashPassword ( password string ) ( string , error ) {
bytes , err := bcrypt . GenerateFromPassword (
[] byte ( password ),
bcrypt . DefaultCost ,
)
return string ( bytes ), err
}
func CheckPassword ( hash , password string ) bool {
err := bcrypt . CompareHashAndPassword ([] byte ( hash ), [] byte ( password ))
return err == nil
}
bcrypt is intentionally slow (adaptive hashing). This protects against brute-force attacks even if the database is compromised.
Signup Flow
internal/auth/auth.go:22-39
func Signup ( ctx context . Context , username , password string ) ( * models . User , * models . Session , error ) {
// Hash password
hash , err := HashPassword ( password )
if err != nil {
return nil , nil , err
}
// Create user
user , err := db . CreateUser ( ctx , username , hash )
if err != nil {
return nil , nil , err
}
// Create session
session , err := db . CreateSession ( ctx , user . ID )
if err != nil {
return nil , nil , err
}
return user , session , nil
}
Hash Password
User’s password is hashed with bcrypt (never stored in plaintext)
Create User Record
User inserted into users table with hashed password
Generate Session
Random session token created and stored in sessions table
Set Cookie
Session token set as HttpOnly cookie in the response
Login Flow
internal/auth/auth.go:41-57
func Login ( ctx context . Context , username , password string ) ( * models . User , * models . Session , error ) {
// Get user by username
user , err := db . GetUserByUsername ( ctx , username )
if err != nil {
return nil , nil , err
}
// Check password
if ! CheckPassword ( user . PasswordHash , password ) {
return nil , nil , fmt . Errorf ( "invalid password" )
}
// Create session
session , err := db . CreateSession ( ctx , user . ID )
if err != nil {
return nil , nil , err
}
return user , session , nil
}
Session Management
Sessions are stored in the database , not in memory or Redis:
internal/models/models.go:54-59
type Session struct {
Token string `json:"token"`
UserID int `json:"user_id"`
ExpiresAt time . Time `json:"expires_at"`
CreatedAt time . Time `json:"created_at"`
}
Creating Sessions
internal/db/sessions.go:11-28
func CreateSession ( ctx context . Context , userID int ) ( * models . Session , error ) {
token , err := generateToken ()
if err != nil {
return nil , err
}
expiresAt := time . Now (). Add ( 7 * 24 * time . Hour ) // 7 days
session := & models . Session {}
err = Pool . QueryRow ( ctx ,
`INSERT INTO sessions (token, user_id, expires_at) VALUES ($1, $2, $3)
RETURNING token, user_id, expires_at, created_at` ,
token , userID , expiresAt ,
). Scan ( & session . Token , & session . UserID , & session . ExpiresAt , & session . CreatedAt )
return session , err
}
Token generation:
internal/db/sessions.go:48-55
func generateToken () ( string , error ) {
bytes := make ([] byte , 32 )
_ , err := rand . Read ( bytes )
if err != nil {
return "" , err
}
return hex . EncodeToString ( bytes ), nil // 64-char hex string
}
Tokens are 64 characters of cryptographically random hex. This provides 256 bits of entropy, making brute-force attacks infeasible.
Validating Sessions
internal/db/sessions.go:30-41
func GetSession ( ctx context . Context , token string ) ( * models . Session , error ) {
session := & models . Session {}
err := Pool . QueryRow ( ctx ,
`SELECT token, user_id, expires_at, created_at FROM sessions
WHERE token = $1 AND expires_at > NOW()` ,
token ,
). Scan ( & session . Token , & session . UserID , & session . ExpiresAt , & session . CreatedAt )
if err != nil {
return nil , err
}
return session , nil
}
Expired sessions are automatically excluded by the expires_at > NOW() condition.
Deleting Sessions (Logout)
internal/db/sessions.go:43-46
func DeleteSession ( ctx context . Context , token string ) error {
_ , err := Pool . Exec ( ctx , `DELETE FROM sessions WHERE token = $1` , token )
return err
}
Middleware
Authentication middleware runs on every request:
LoadUser Middleware
Attempts to load the user from the session cookie (non-blocking):
internal/auth/middleware.go:15-49
func LoadUser () gin . HandlerFunc {
return func ( c * gin . Context ) {
token , err := c . Cookie ( SessionCookieName )
if err != nil || token == "" {
c . Next ()
return
}
session , err := db . GetSession ( c . Request . Context (), token )
if err != nil {
c . Next ()
return
}
user , err := db . GetUserByID ( c . Request . Context (), session . UserID )
if err != nil {
c . Next ()
return
}
// Load roles and derive admin status
roles , _ := db . GetUserRoles ( c . Request . Context (), user . ID )
user . Roles = roles
user . IsAdmin = false
for _ , r := range roles {
if r . IsAdminRank {
user . IsAdmin = true
break
}
}
c . Set ( "user" , user )
c . Next ()
}
}
Applied globally:
LoadUser doesn’t block anonymous access — it just sets "user" in the context if authenticated.
CheckBan Middleware
Blocks banned users from the entire site:
internal/auth/middleware.go:53-82
func CheckBan () gin . HandlerFunc {
return func ( c * gin . Context ) {
user := GetCurrentUser ( c )
if user == nil || ! user . IsBanned () {
c . Next ()
return
}
// Allow logout so they can sign out
if c . Request . URL . Path == "/logout" && c . Request . Method == "POST" {
c . Next ()
return
}
isPermanent := user . BannedUntil != nil &&
user . BannedUntil . After ( time . Now (). Add ( 50 * 365 * 24 * time . Hour ))
c . HTML ( http . StatusForbidden , "banned.html" , gin . H {
"BannedUntil" : user . BannedUntil ,
"IsPermanent" : isPermanent ,
})
c . Abort ()
}
}
Applied globally after LoadUser:
RequireAuth Middleware
Redirects to login if not authenticated:
internal/auth/middleware.go:85-93
func RequireAuth () gin . HandlerFunc {
return func ( c * gin . Context ) {
if _ , exists := c . Get ( "user" ); ! exists {
c . Redirect ( 302 , "/login" )
c . Abort ()
return
}
c . Next ()
}
}
Usage:
authorized := r . Group ( "/" )
authorized . Use ( auth . RequireAuth ())
{
authorized . GET ( "/submit" , handlers . SubmitPage )
authorized . POST ( "/submit" , handlers . SubmitPost )
authorized . POST ( "/upvote/post/:id" , handlers . UpvotePost )
}
RequireAdmin Middleware
Redirects to home if not admin:
internal/auth/middleware.go:117-127
func RequireAdmin () gin . HandlerFunc {
return func ( c * gin . Context ) {
user := GetCurrentUser ( c )
if user == nil || ! user . IsAdmin {
c . Redirect ( 302 , "/" )
c . Abort ()
return
}
c . Next ()
}
}
Usage:
admin := r . Group ( "/admin" )
admin . Use ( auth . RequireAuth (), auth . RequireAdmin ())
{
admin . GET ( "" , handlers . AdminDashboard )
admin . GET ( "/users" , handlers . AdminUsers )
admin . POST ( "/ban/:id" , handlers . AdminBanUser )
}
Cookie Handling
Session cookies are HttpOnly and set during login/signup:
handlers/auth.go (simplified)
func LoginSubmit ( c * gin . Context ) {
username := c . PostForm ( "username" )
password := c . PostForm ( "password" )
user , session , err := auth . Login ( c . Request . Context (), username , password )
if err != nil {
// ... error handling
}
// Set session cookie
c . SetCookie (
auth . SessionCookieName , // "session_token"
session . Token ,
int ( 7 * 24 * 60 * 60 ), // 7 days in seconds
"/" , // Path
"" , // Domain (empty = current)
false , // Secure (should be true in production)
true , // HttpOnly (prevents JS access)
)
c . Redirect ( 302 , "/" )
}
Cookie name:
internal/auth/middleware.go:12
const SessionCookieName = "session_token"
In production, set Secure: true when using HTTPS to prevent cookie interception.
Helper Functions
GetCurrentUser
internal/auth/middleware.go:96-106
func GetCurrentUser ( c * gin . Context ) * models . User {
val , exists := c . Get ( "user" )
if ! exists {
return nil
}
user , ok := val .( * models . User )
if ! ok {
return nil
}
return user
}
Usage in handlers:
func MyHandler ( c * gin . Context ) {
user := auth . GetCurrentUser ( c )
if user == nil {
// Anonymous user
} else {
// Logged in as user
}
}
GetCurrentUserID
internal/auth/middleware.go:108-114
func GetCurrentUserID ( c * gin . Context ) * int {
user := GetCurrentUser ( c )
if user == nil {
return nil
}
return & user . ID
}
Useful for queries:
post , _ := db . GetPost ( ctx , postID , auth . GetCurrentUserID ( c ))
// Passes nil if anonymous, user ID if logged in
Security Considerations
CSRF Protection
oForum currently does not implement CSRF protection . All state-changing operations should use POST with proper validation.
For production, consider:
CSRF tokens in forms
SameSite cookie attribute
Referrer checking
SQL Injection
✅ Protected — all queries use parameterized statements:
// Safe - uses $1 placeholder
db . Pool . QueryRow ( ctx , "SELECT * FROM users WHERE username = $1" , username )
// ❌ NEVER do this
query := fmt . Sprintf ( "SELECT * FROM users WHERE username = ' %s '" , username )
XSS Protection
✅ Protected — Go’s html/template auto-escapes all data:
<!-- Automatic escaping -->
< div > {{ .Post.Title }} </ div >
<!-- Manual escaping for trusted content -->
< div > {{ formatContent .Post.Body }} </ div > <!-- Escapes first, then adds links -->
Password Requirements
oForum currently has no password strength requirements . Consider adding validation for production use.
Recommended additions:
func ValidatePassword ( password string ) error {
if len ( password ) < 8 {
return fmt . Errorf ( "password must be at least 8 characters" )
}
// Add more checks (numbers, symbols, etc.)
return nil
}
Rate Limiting
No rate limiting is implemented. For production, add middleware to limit:
Login attempts per IP
Signup attempts per IP
Post/comment creation per user
Role-Based Access Control
Admin status is derived from the roles table:
internal/models/models.go:23-30
type Role struct {
ID int `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
SortOrder int `json:"sort_order"`
IsAdminRank bool `json:"is_admin_rank"`
CreatedAt time . Time `json:"created_at"`
}
Determining admin status:
internal/auth/middleware.go:36-44
roles , _ := db . GetUserRoles ( c . Request . Context (), user . ID )
user . Roles = roles
user . IsAdmin = false
for _ , r := range roles {
if r . IsAdminRank {
user . IsAdmin = true
break
}
}
This allows multiple admin roles with different names/colors, all granting admin access if is_admin_rank = true.
Testing Authentication
Manual Testing
Signup:
curl -X POST http://localhost:8080/signup \
-d "username=testuser" \
-d "password=testpass" \
-c cookies.txt
Login:
curl -X POST http://localhost:8080/login \
-d "username=testuser" \
-d "password=testpass" \
-c cookies.txt
Access Protected Route:
curl http://localhost:8080/submit -b cookies.txt
Unit Testing
func TestPasswordHashing ( t * testing . T ) {
password := "mysecretpassword"
hash , err := auth . HashPassword ( password )
if err != nil {
t . Fatal ( err )
}
if ! auth . CheckPassword ( hash , password ) {
t . Error ( "Password check failed" )
}
if auth . CheckPassword ( hash , "wrongpassword" ) {
t . Error ( "Wrong password accepted" )
}
}
Next Steps
Architecture Understand how authentication fits into the request flow
Database Learn about session storage and user queries