Compare commits

..

No commits in common. "master" and "v1.1.1" have entirely different histories.

7 changed files with 63 additions and 355 deletions

171
README.md
View File

@ -15,7 +15,7 @@ func main() {
// Initialize sessions // Initialize sessions
sushi.InitSessions("sessions.json") sushi.InitSessions("sessions.json")
app.Get("/", func(ctx sushi.Ctx) { app.Get("/", func(ctx sushi.Ctx, params []any) {
ctx.SendHTML("<h1>Hello Sushi!</h1>") ctx.SendHTML("<h1>Hello Sushi!</h1>")
}) })
@ -41,86 +41,35 @@ api.Get("/users", listUsersHandler)
api.Post("/users", createUserHandler) api.Post("/users", createUserHandler)
``` ```
## Parameters and Queries ## Parameters
Sushi provides a fluent API for accessing URL parameters, query strings, and form data with automatic type conversion: URL parameters are automatically converted to the appropriate type:
### URL Parameters (Named Routes)
```go ```go
app.Get("/users/:id", func(ctx sushi.Ctx) { // Numeric parameters become integers
// Get URL parameter with fluent API app.Get("/users/:id", func(ctx sushi.Ctx, params []any) {
userID := ctx.Param("id").Int() // /users/123 -> 123 userID := params[0].(int) // /users/123 -> 123
userIDStr := ctx.Param("id").String() // /users/123 -> "123" // ...
// With defaults
limit := ctx.Param("limit").IntDefault(10)
}) })
app.Get("/users/:id/posts/:slug", func(ctx sushi.Ctx) { // String parameters stay strings
userID := ctx.Param("id").Int() app.Get("/users/:name", func(ctx sushi.Ctx, params []any) {
slug := ctx.Param("slug").String() name := params[0].(string) // /users/john -> "john"
// ...
// Check if parameter exists
if ctx.Param("optional").Exists() {
// Handle optional parameter
}
}) })
```
### Query Parameters // Mixed types
app.Get("/users/:id/posts/:slug", func(ctx sushi.Ctx, params []any) {
```go userID := params[0].(int) // 123
app.Get("/search", func(ctx sushi.Ctx) { slug := params[1].(string) // "my-post"
// Get query parameters with fluent API // ...
query := ctx.Query("q").String() // ?q=hello -> "hello"
page := ctx.Query("page").IntDefault(1) // ?page=2 -> 2
limit := ctx.Query("limit").IntDefault(20) // Default to 20 if not present
sortBy := ctx.Query("sort").StringDefault("name") // Default to "name"
// Boolean query params
includeDeleted := ctx.Query("deleted").Bool() // ?deleted=true -> true
// Float values
minPrice := ctx.Query("min_price").Float() // ?min_price=19.99 -> 19.99
// Check existence
if ctx.Query("filter").Exists() {
filter := ctx.Query("filter").String()
// Apply filter
}
})
```
## Form Data
Access form data with the fluent API:
```go
app.Post("/users", func(ctx sushi.Ctx) {
// Get form fields with fluent API
name := ctx.Input("name").String()
email := ctx.Input("email").String()
age := ctx.Input("age").IntDefault(0)
// Check if checkbox is checked
agreeToTerms := ctx.Input("terms").Bool() // Checks for "true", "on", "1", "yes"
// Validate required fields
if ctx.Input("email").IsEmpty() {
ctx.SendError(400, "Email is required")
return
}
// Get array of values (for multiple select, checkboxes)
tags := ctx.GetFormArray("tags[]")
}) })
``` ```
## Response Helpers ## Response Helpers
```go ```go
func myHandler(ctx sushi.Ctx) { func myHandler(ctx sushi.Ctx, params []any) {
// JSON responses // JSON responses
ctx.SendJSON(map[string]string{"message": "success"}) ctx.SendJSON(map[string]string{"message": "success"})
@ -138,20 +87,15 @@ func myHandler(ctx sushi.Ctx) {
// Status only // Status only
ctx.SendStatus(204) ctx.SendStatus(204)
// File responses
ctx.SendFile("/path/to/file.pdf")
// Raw bytes
ctx.SendBytes(imageData, "image/png")
} }
```
## Middleware ## Middleware
```go ```go
// Custom middleware // Custom middleware
func loggingMiddleware() sushi.Middleware { func loggingMiddleware() sushi.Middleware {
return func(ctx sushi.Ctx, next func()) { return func(ctx sushi.Ctx, params []any, next func()) {
println("Request:", string(ctx.Method()), string(ctx.Path())) println("Request:", string(ctx.Method()), string(ctx.Path()))
next() next()
println("Status:", ctx.Response.StatusCode()) println("Status:", ctx.Response.StatusCode())
@ -167,51 +111,16 @@ admin.Use(auth.RequireAuth("/login"))
## Authentication Workflow ## Authentication Workflow
### 1. Password Hashing ### 1. Setup Password Hashing
The password package supports both Argon2 (default) and Bcrypt algorithms with configurable settings:
```go ```go
import "git.sharkk.net/Sharkk/Sushi/password" import "git.sharkk.net/Sharkk/Sushi/password"
// Using default Argon2 configuration // Hash password for storage
hashedPassword := password.HashPassword("userpassword123") hashedPassword := password.HashPassword("userpassword123")
// Verify password (auto-detects algorithm) // Verify password during login
isValid, err := password.VerifyPassword("userpassword123", hashedPassword) isValid, err := password.VerifyPassword("userpassword123", hashedPassword)
// Configure for Bcrypt
password.SetConfig(password.Config{
Algorithm: password.Bcrypt,
Bcrypt: password.BcryptConfig{
Cost: 12, // Higher = more secure but slower
},
})
// Configure for Argon2 with custom settings
password.SetConfig(password.Config{
Algorithm: password.Argon2,
Argon2: password.Argon2Config{
Time: 3, // Iterations
Memory: 128 * 1024, // 128 MB
Threads: 4,
KeyLen: 32,
},
})
// Use preset configurations
password.SetConfig(password.Config{
Algorithm: password.Argon2,
Argon2: password.SecureArgon2Config(), // More secure, slower
// Or: password.FastArgon2Config() for development
})
// Check if password needs rehashing (algorithm or params changed)
if password.NeedsRehash(userHashedPassword) {
// Rehash password after successful verification
newHash := password.HashPassword(plainPassword)
// Update stored hash in database
}
``` ```
### 2. User Structure ### 2. User Structure
@ -267,16 +176,9 @@ func main() {
### 4. Login Handler ### 4. Login Handler
```go ```go
func loginHandler(ctx sushi.Ctx) { func loginHandler(ctx sushi.Ctx, params []any) {
// Use fluent API for form data email := string(ctx.PostArgs().Peek("email"))
email := ctx.Input("email").String() password := string(ctx.PostArgs().Peek("password"))
password := ctx.Input("password").String()
// Validate inputs
if ctx.Input("email").IsEmpty() || ctx.Input("password").IsEmpty() {
ctx.SendError(400, "Email and password are required")
return
}
// Find user by email/username // Find user by email/username
user := findUserByEmail(email) user := findUserByEmail(email)
@ -302,7 +204,7 @@ func loginHandler(ctx sushi.Ctx) {
### 5. Logout Handler ### 5. Logout Handler
```go ```go
func logoutHandler(ctx sushi.Ctx) { func logoutHandler(ctx sushi.Ctx, params []any) {
ctx.Logout() ctx.Logout()
ctx.Redirect("/") ctx.Redirect("/")
} }
@ -311,7 +213,7 @@ func logoutHandler(ctx sushi.Ctx) {
### 6. Getting Current User ### 6. Getting Current User
```go ```go
func dashboardHandler(ctx sushi.Ctx) { func dashboardHandler(ctx sushi.Ctx, params []any) {
user := ctx.GetCurrentUser().(*User) user := ctx.GetCurrentUser().(*User)
html := fmt.Sprintf("<h1>Welcome, %s!</h1>", user.Username) html := fmt.Sprintf("<h1>Welcome, %s!</h1>", user.Username)
@ -328,7 +230,7 @@ import "git.sharkk.net/Sharkk/Sushi/csrf"
app.Use(csrf.Middleware()) app.Use(csrf.Middleware())
// In your form template // In your form template
func loginPageHandler(ctx sushi.Ctx) { func loginPageHandler(ctx sushi.Ctx, params []any) {
csrfField := csrf.CSRFHiddenField(ctx) csrfField := csrf.CSRFHiddenField(ctx)
html := fmt.Sprintf(` html := fmt.Sprintf(`
@ -364,7 +266,7 @@ app.Get("/assets/*path", sushi.StaticEmbed(files))
## Sessions ## Sessions
```go ```go
func someHandler(ctx sushi.Ctx) { func someHandler(ctx sushi.Ctx, params []any) {
sess := ctx.GetCurrentSession() sess := ctx.GetCurrentSession()
// Set session data // Set session data
@ -455,7 +357,7 @@ func main() {
app.Listen(":8080") app.Listen(":8080")
} }
func homeHandler(ctx sushi.Ctx) { func homeHandler(ctx sushi.Ctx, params []any) {
if ctx.IsAuthenticated() { if ctx.IsAuthenticated() {
ctx.Redirect("/dashboard") ctx.Redirect("/dashboard")
return return
@ -463,7 +365,7 @@ func homeHandler(ctx sushi.Ctx) {
ctx.SendHTML(`<a href="/login">Login</a>`) ctx.SendHTML(`<a href="/login">Login</a>`)
} }
func loginPageHandler(ctx sushi.Ctx) { func loginPageHandler(ctx sushi.Ctx, params []any) {
html := fmt.Sprintf(` html := fmt.Sprintf(`
<form method="POST" action="/login"> <form method="POST" action="/login">
%s %s
@ -476,10 +378,9 @@ func loginPageHandler(ctx sushi.Ctx) {
ctx.SendHTML(html) ctx.SendHTML(html)
} }
func loginHandler(ctx sushi.Ctx) { func loginHandler(ctx sushi.Ctx, params []any) {
// Use fluent API for form data email := string(ctx.PostArgs().Peek("email"))
email := ctx.Input("email").String() pass := string(ctx.PostArgs().Peek("password"))
pass := ctx.Input("password").String()
user := findUserByEmail(email) user := findUserByEmail(email)
if user == nil { if user == nil {
@ -496,7 +397,7 @@ func loginHandler(ctx sushi.Ctx) {
ctx.Redirect("/dashboard") ctx.Redirect("/dashboard")
} }
func dashboardHandler(ctx sushi.Ctx) { func dashboardHandler(ctx sushi.Ctx, params []any) {
user := ctx.GetCurrentUser().(*User) user := ctx.GetCurrentUser().(*User)
html := fmt.Sprintf(` html := fmt.Sprintf(`
@ -510,7 +411,7 @@ func dashboardHandler(ctx sushi.Ctx) {
ctx.SendHTML(html) ctx.SendHTML(html)
} }
func logoutHandler(ctx sushi.Ctx) { func logoutHandler(ctx sushi.Ctx, params []any) {
ctx.Logout() ctx.Logout()
ctx.Redirect("/") ctx.Redirect("/")
} }

View File

@ -7,22 +7,12 @@ import (
const UserCtxKey = "user" const UserCtxKey = "user"
// Auth holds the authentication middleware and user lookup function // Middleware adds authentication handling
type Auth struct { func Middleware(userLookup func(int) any) sushi.Middleware {
userLookup func(int) any
}
// New creates a new Auth instance
func New(userLookup func(int) any) *Auth {
return &Auth{userLookup: userLookup}
}
// Middleware returns the authentication middleware function
func (a *Auth) Middleware() sushi.Middleware {
return func(ctx sushi.Ctx, next func()) { return func(ctx sushi.Ctx, next func()) {
sess := sushi.GetCurrentSession(ctx) sess := sushi.GetCurrentSession(ctx)
if sess != nil && sess.UserID > 0 && a.userLookup != nil { if sess != nil && sess.UserID > 0 && userLookup != nil {
user := a.userLookup(sess.UserID) user := userLookup(sess.UserID)
if user != nil { if user != nil {
ctx.SetUserValue(UserCtxKey, user) ctx.SetUserValue(UserCtxKey, user)
} else { } else {
@ -34,15 +24,6 @@ func (a *Auth) Middleware() sushi.Middleware {
} }
} }
// Update refreshes the current user data in the context
func (a *Auth) Update(ctx sushi.Ctx) {
sess := sushi.GetCurrentSession(ctx)
if sess != nil && sess.UserID > 0 && a.userLookup != nil {
user := a.userLookup(sess.UserID)
ctx.SetUserValue(UserCtxKey, user)
}
}
// RequireAuth middleware that redirects unauthenticated users // RequireAuth middleware that redirects unauthenticated users
func RequireAuth(redirectPath ...string) sushi.Middleware { func RequireAuth(redirectPath ...string) sushi.Middleware {
redirect := "/login" redirect := "/login"

View File

@ -10,18 +10,13 @@ type FormValue struct {
exists bool exists bool
} }
// Input gets a form field for chaining // Form gets a form field for chaining
func (ctx Ctx) Input(key string) FormValue { func (ctx Ctx) Form(key string) FormValue {
value := string(ctx.PostArgs().Peek(key)) value := string(ctx.PostArgs().Peek(key))
exists := ctx.PostArgs().Has(key) exists := ctx.PostArgs().Has(key)
return FormValue{value: value, exists: exists} return FormValue{value: value, exists: exists}
} }
// Form gets a form field for chaining (deprecated: use Input instead)
func (ctx Ctx) Form(key string) FormValue {
return ctx.Input(key)
}
// String returns the value as string // String returns the value as string
func (f FormValue) String() string { func (f FormValue) String() string {
return f.value return f.value

View File

@ -5,12 +5,23 @@ import (
"strings" "strings"
) )
const RouteParamsCtxKey = "route_params"
type ParamValue struct { type ParamValue struct {
value string value string
exists bool exists bool
} }
// RouteParam gets a route parameter by index for chaining
func (ctx Ctx) RouteParam(index int) ParamValue {
if params, ok := ctx.UserValue(RouteParamsCtxKey).([]string); ok {
if index >= 0 && index < len(params) {
return ParamValue{value: params[index], exists: true}
}
}
return ParamValue{value: "", exists: false}
}
// Param gets a route parameter by name for chaining (requires named params) // Param gets a route parameter by name for chaining (requires named params)
func (ctx Ctx) Param(name string) ParamValue { func (ctx Ctx) Param(name string) ParamValue {
if paramMap, ok := ctx.UserValue("param_names").(map[string]string); ok { if paramMap, ok := ctx.UserValue("param_names").(map[string]string); ok {
@ -21,13 +32,6 @@ func (ctx Ctx) Param(name string) ParamValue {
return ParamValue{value: "", exists: false} return ParamValue{value: "", exists: false}
} }
// Query gets a query parameter for chaining
func (ctx Ctx) Query(key string) ParamValue {
value := string(ctx.QueryArgs().Peek(key))
exists := ctx.QueryArgs().Has(key)
return ParamValue{value: value, exists: exists}
}
// String returns the value as string // String returns the value as string
func (p ParamValue) String() string { func (p ParamValue) String() string {
return p.value return p.value

View File

@ -8,158 +8,33 @@ import (
"strings" "strings"
"golang.org/x/crypto/argon2" "golang.org/x/crypto/argon2"
"golang.org/x/crypto/bcrypt"
) )
// Algorithm represents the hashing algorithm to use
type Algorithm int
const ( const (
Argon2 Algorithm = iota argonTime = 1
Bcrypt argonMemory = 64 * 1024
argonThreads = 4
argonKeyLen = 32
) )
// Argon2Config holds configuration for Argon2 hashing // HashPassword creates an argon2id hash of the password
type Argon2Config struct {
Time uint32 // Number of iterations
Memory uint32 // Memory usage in KB
Threads uint8 // Number of threads
KeyLen uint32 // Length of generated hash
}
// BcryptConfig holds configuration for Bcrypt hashing
type BcryptConfig struct {
Cost int // Bcrypt cost factor (4-31)
}
// Config holds the password hashing configuration
type Config struct {
Algorithm Algorithm
Argon2 Argon2Config
Bcrypt BcryptConfig
}
// DefaultArgon2Config returns recommended Argon2 settings
func DefaultArgon2Config() Argon2Config {
return Argon2Config{
Time: 1,
Memory: 64 * 1024, // 64 MB
Threads: 4,
KeyLen: 32,
}
}
// SecureArgon2Config returns more secure Argon2 settings (slower but more resistant)
func SecureArgon2Config() Argon2Config {
return Argon2Config{
Time: 3,
Memory: 128 * 1024, // 128 MB
Threads: 4,
KeyLen: 32,
}
}
// FastArgon2Config returns faster Argon2 settings (for testing/development)
func FastArgon2Config() Argon2Config {
return Argon2Config{
Time: 1,
Memory: 32 * 1024, // 32 MB
Threads: 2,
KeyLen: 32,
}
}
// DefaultBcryptConfig returns recommended Bcrypt settings
func DefaultBcryptConfig() BcryptConfig {
return BcryptConfig{
Cost: bcrypt.DefaultCost, // 10
}
}
// SecureBcryptConfig returns more secure Bcrypt settings (slower but more resistant)
func SecureBcryptConfig() BcryptConfig {
return BcryptConfig{
Cost: 12,
}
}
// DefaultConfig returns the default configuration (Argon2 with default settings)
func DefaultConfig() Config {
return Config{
Algorithm: Argon2,
Argon2: DefaultArgon2Config(),
Bcrypt: DefaultBcryptConfig(),
}
}
var globalConfig = DefaultConfig()
// SetConfig sets the global password configuration
func SetConfig(config Config) {
globalConfig = config
}
// GetConfig returns the current global password configuration
func GetConfig() Config {
return globalConfig
}
// HashPassword creates a hash of the password using the global configuration
func HashPassword(password string) string { func HashPassword(password string) string {
return HashPasswordWithConfig(password, globalConfig)
}
// HashPasswordWithConfig creates a hash of the password using the specified configuration
func HashPasswordWithConfig(password string, config Config) string {
switch config.Algorithm {
case Bcrypt:
return hashBcrypt(password, config.Bcrypt)
case Argon2:
fallthrough
default:
return hashArgon2(password, config.Argon2)
}
}
// hashArgon2 creates an argon2id hash of the password
func hashArgon2(password string, config Argon2Config) string {
salt := make([]byte, 16) salt := make([]byte, 16)
rand.Read(salt) rand.Read(salt)
hash := argon2.IDKey([]byte(password), salt, config.Time, config.Memory, config.Threads, config.KeyLen) hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
b64Salt := base64.RawStdEncoding.EncodeToString(salt) b64Salt := base64.RawStdEncoding.EncodeToString(salt)
b64Hash := base64.RawStdEncoding.EncodeToString(hash) b64Hash := base64.RawStdEncoding.EncodeToString(hash)
encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, config.Memory, config.Time, config.Threads, b64Salt, b64Hash) argon2.Version, argonMemory, argonTime, argonThreads, b64Salt, b64Hash)
return encoded return encoded
} }
// hashBcrypt creates a bcrypt hash of the password
func hashBcrypt(password string, config BcryptConfig) string {
hash, err := bcrypt.GenerateFromPassword([]byte(password), config.Cost)
if err != nil {
// Fallback to default cost if provided cost is invalid
hash, _ = bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
}
return string(hash)
}
// VerifyPassword checks if a password matches the hash // VerifyPassword checks if a password matches the hash
func VerifyPassword(password, encodedHash string) (bool, error) { func VerifyPassword(password, encodedHash string) (bool, error) {
// Detect hash type by prefix
if strings.HasPrefix(encodedHash, "$2a$") || strings.HasPrefix(encodedHash, "$2b$") || strings.HasPrefix(encodedHash, "$2y$") {
return verifyBcrypt(password, encodedHash)
} else if strings.HasPrefix(encodedHash, "$argon2id$") {
return verifyArgon2(password, encodedHash)
}
return false, fmt.Errorf("unsupported hash format")
}
// verifyArgon2 checks if a password matches an argon2 hash
func verifyArgon2(password, encodedHash string) (bool, error) {
parts := strings.Split(encodedHash, "$") parts := strings.Split(encodedHash, "$")
if len(parts) != 6 { if len(parts) != 6 {
return false, fmt.Errorf("invalid hash format") return false, fmt.Errorf("invalid hash format")
@ -202,52 +77,3 @@ func verifyArgon2(password, encodedHash string) (bool, error) {
return false, nil return false, nil
} }
// verifyBcrypt checks if a password matches a bcrypt hash
func verifyBcrypt(password, encodedHash string) (bool, error) {
err := bcrypt.CompareHashAndPassword([]byte(encodedHash), []byte(password))
if err == bcrypt.ErrMismatchedHashAndPassword {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
// NeedsRehash checks if a hash needs to be updated to current configuration
func NeedsRehash(encodedHash string) bool {
// Check if using bcrypt when we want argon2
if globalConfig.Algorithm == Argon2 && (strings.HasPrefix(encodedHash, "$2a$") || strings.HasPrefix(encodedHash, "$2b$") || strings.HasPrefix(encodedHash, "$2y$")) {
return true
}
// Check if using argon2 when we want bcrypt
if globalConfig.Algorithm == Bcrypt && strings.HasPrefix(encodedHash, "$argon2id$") {
return true
}
// For bcrypt, check if cost has changed
if strings.HasPrefix(encodedHash, "$2") {
cost, err := bcrypt.Cost([]byte(encodedHash))
if err == nil && cost != globalConfig.Bcrypt.Cost {
return true
}
}
// For argon2, check if parameters have changed
if strings.HasPrefix(encodedHash, "$argon2id$") {
parts := strings.Split(encodedHash, "$")
if len(parts) == 6 {
var m, t, p uint32
_, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p)
if err == nil {
if m != globalConfig.Argon2.Memory || t != globalConfig.Argon2.Time || p != uint32(globalConfig.Argon2.Threads) {
return true
}
}
}
}
return false
}

View File

@ -57,8 +57,10 @@ func (r *Router) ServeHTTP(ctx *fasthttp.RequestCtx) {
} }
// Store params in context // Store params in context
sushiCtx := Ctx{RequestCtx: ctx, Params: params} sushiCtx := Ctx{ctx}
if len(params) > 0 { if len(params) > 0 {
sushiCtx.SetUserValue(RouteParamsCtxKey, params)
// Create named param map if param names exist // Create named param map if param names exist
if len(paramNames) > 0 { if len(paramNames) > 0 {
paramMap := make(map[string]string) paramMap := make(map[string]string)

View File

@ -8,7 +8,6 @@ import (
type Ctx struct { type Ctx struct {
*fasthttp.RequestCtx *fasthttp.RequestCtx
Params []string
} }
type Handler func(ctx Ctx) type Handler func(ctx Ctx)