Compare commits
5 Commits
jwt/v0.10.
...
hws/v0.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c2ca4d79a | |||
| 3726ad738a | |||
| 423a9ee26d | |||
| 9f98bbce2d | |||
| 4c5af63ea2 |
2
hws/.gitignore
vendored
2
hws/.gitignore
vendored
@@ -17,3 +17,5 @@ coverage.html
|
|||||||
|
|
||||||
# Go workspace file
|
# Go workspace file
|
||||||
go.work
|
go.work
|
||||||
|
|
||||||
|
.claude/
|
||||||
|
|||||||
21
hws/LICENSE
Normal file
21
hws/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 haelnorr
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
119
hws/README.md
Normal file
119
hws/README.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# HWS (H Web Server)
|
||||||
|
|
||||||
|
[](https://pkg.go.dev/git.haelnorr.com/h/golib/hws)
|
||||||
|
|
||||||
|
A lightweight, opinionated HTTP web server framework for Go built on top of the standard library's `net/http`.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🚀 Built on Go 1.22+ routing patterns with method and path matching
|
||||||
|
- 🎯 Structured error handling with customizable error pages
|
||||||
|
- 📝 Integrated logging with zerolog via hlog
|
||||||
|
- 🔧 Middleware support with predictable execution order
|
||||||
|
- 🗜️ GZIP compression support
|
||||||
|
- 🔒 Safe static file serving (prevents directory listing)
|
||||||
|
- ⚙️ Environment variable configuration
|
||||||
|
- ⏱️ Request timing and logging middleware
|
||||||
|
- 💚 Graceful shutdown support
|
||||||
|
- 🏥 Built-in health check endpoint
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go get git.haelnorr.com/h/golib/hws
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Load configuration from environment variables
|
||||||
|
config, _ := hws.ConfigFromEnv()
|
||||||
|
|
||||||
|
// Create server
|
||||||
|
server, _ := hws.NewServer(config)
|
||||||
|
|
||||||
|
// Define routes
|
||||||
|
routes := []hws.Route{
|
||||||
|
{
|
||||||
|
Path: "/",
|
||||||
|
Method: hws.MethodGET,
|
||||||
|
Handler: http.HandlerFunc(homeHandler),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/api/users/{id}",
|
||||||
|
Method: hws.MethodGET,
|
||||||
|
Handler: http.HandlerFunc(getUserHandler),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add routes and middleware
|
||||||
|
server.AddRoutes(routes...)
|
||||||
|
server.AddMiddleware()
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
ctx := context.Background()
|
||||||
|
server.Start(ctx)
|
||||||
|
|
||||||
|
// Wait for server to be ready
|
||||||
|
<-server.Ready()
|
||||||
|
}
|
||||||
|
|
||||||
|
func homeHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("Hello, World!"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
w.Write([]byte("User ID: " + id))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Comprehensive documentation is available in the [Wiki](https://git.haelnorr.com/h/golib/wiki/hws).
|
||||||
|
|
||||||
|
### Key Topics
|
||||||
|
|
||||||
|
- [Configuration](https://git.haelnorr.com/h/golib/wiki/hws#configuration)
|
||||||
|
- [Routing](https://git.haelnorr.com/h/golib/wiki/hws#routing)
|
||||||
|
- [Middleware](https://git.haelnorr.com/h/golib/wiki/hws#middleware)
|
||||||
|
- [Error Handling](https://git.haelnorr.com/h/golib/wiki/hws#error-handling)
|
||||||
|
- [Logging](https://git.haelnorr.com/h/golib/wiki/hws#logging)
|
||||||
|
- [Static Files](https://git.haelnorr.com/h/golib/wiki/hws#static-files)
|
||||||
|
- [Graceful Shutdown](https://git.haelnorr.com/h/golib/wiki/hws#graceful-shutdown)
|
||||||
|
- [Complete Examples](https://git.haelnorr.com/h/golib/wiki/hws#complete-production-example)
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `HWS_HOST` | Host to listen on | `127.0.0.1` |
|
||||||
|
| `HWS_PORT` | Port to listen on | `3000` |
|
||||||
|
| `HWS_TRUSTED_HOST` | Trusted hostname/domain | Same as Host |
|
||||||
|
| `HWS_GZIP` | Enable GZIP compression | `false` |
|
||||||
|
| `HWS_READ_HEADER_TIMEOUT` | Header read timeout (seconds) | `2` |
|
||||||
|
| `HWS_WRITE_TIMEOUT` | Write timeout (seconds) | `10` |
|
||||||
|
| `HWS_IDLE_TIMEOUT` | Idle connection timeout (seconds) | `120` |
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||||
|
|
||||||
|
## Related Projects
|
||||||
|
|
||||||
|
- [HWSAuth](https://git.haelnorr.com/h/golib/hwsauth) - JWT authentication middleware for HWS
|
||||||
|
- [hlog](https://git.haelnorr.com/h/golib/hlog) - Structured logging with zerolog
|
||||||
|
- [jwt](https://git.haelnorr.com/h/golib/jwt) - JWT token generation and validation
|
||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
Host string // ENV HWS_HOST: Host to listen on (default: 127.0.0.1)
|
Host string // ENV HWS_HOST: Host to listen on (default: 127.0.0.1)
|
||||||
Port uint64 // ENV HWS_PORT: Port to listen on (default: 3000)
|
Port uint64 // ENV HWS_PORT: Port to listen on (default: 3000)
|
||||||
TrustedHost string // ENV HWS_TRUSTED_HOST: Domain/Hostname to accept as trusted (default: same as Host)
|
|
||||||
GZIP bool // ENV HWS_GZIP: Flag for GZIP compression on requests (default: false)
|
GZIP bool // ENV HWS_GZIP: Flag for GZIP compression on requests (default: false)
|
||||||
ReadHeaderTimeout time.Duration // ENV HWS_READ_HEADER_TIMEOUT: Timeout for reading request headers in seconds (default: 2)
|
ReadHeaderTimeout time.Duration // ENV HWS_READ_HEADER_TIMEOUT: Timeout for reading request headers in seconds (default: 2)
|
||||||
WriteTimeout time.Duration // ENV HWS_WRITE_TIMEOUT: Timeout for writing requests in seconds (default: 10)
|
WriteTimeout time.Duration // ENV HWS_WRITE_TIMEOUT: Timeout for writing requests in seconds (default: 10)
|
||||||
@@ -18,13 +17,9 @@ type Config struct {
|
|||||||
|
|
||||||
// ConfigFromEnv returns a Config struct loaded from the environment variables
|
// ConfigFromEnv returns a Config struct loaded from the environment variables
|
||||||
func ConfigFromEnv() (*Config, error) {
|
func ConfigFromEnv() (*Config, error) {
|
||||||
host := env.String("HWS_HOST", "127.0.0.1")
|
|
||||||
trustedHost := env.String("HWS_TRUSTED_HOST", host)
|
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
Host: host,
|
Host: env.String("HWS_HOST", "127.0.0.1"),
|
||||||
Port: env.UInt64("HWS_PORT", 3000),
|
Port: env.UInt64("HWS_PORT", 3000),
|
||||||
TrustedHost: trustedHost,
|
|
||||||
GZIP: env.Bool("HWS_GZIP", false),
|
GZIP: env.Bool("HWS_GZIP", false),
|
||||||
ReadHeaderTimeout: time.Duration(env.Int("HWS_READ_HEADER_TIMEOUT", 2)) * time.Second,
|
ReadHeaderTimeout: time.Duration(env.Int("HWS_READ_HEADER_TIMEOUT", 2)) * time.Second,
|
||||||
WriteTimeout: time.Duration(env.Int("HWS_WRITE_TIMEOUT", 10)) * time.Second,
|
WriteTimeout: time.Duration(env.Int("HWS_WRITE_TIMEOUT", 10)) * time.Second,
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ func Test_ConfigFromEnv(t *testing.T) {
|
|||||||
// Clear any existing env vars
|
// Clear any existing env vars
|
||||||
os.Unsetenv("HWS_HOST")
|
os.Unsetenv("HWS_HOST")
|
||||||
os.Unsetenv("HWS_PORT")
|
os.Unsetenv("HWS_PORT")
|
||||||
os.Unsetenv("HWS_TRUSTED_HOST")
|
|
||||||
os.Unsetenv("HWS_GZIP")
|
os.Unsetenv("HWS_GZIP")
|
||||||
os.Unsetenv("HWS_READ_HEADER_TIMEOUT")
|
os.Unsetenv("HWS_READ_HEADER_TIMEOUT")
|
||||||
os.Unsetenv("HWS_WRITE_TIMEOUT")
|
os.Unsetenv("HWS_WRITE_TIMEOUT")
|
||||||
@@ -27,7 +26,6 @@ func Test_ConfigFromEnv(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, "127.0.0.1", config.Host)
|
assert.Equal(t, "127.0.0.1", config.Host)
|
||||||
assert.Equal(t, uint64(3000), config.Port)
|
assert.Equal(t, uint64(3000), config.Port)
|
||||||
assert.Equal(t, "127.0.0.1", config.TrustedHost)
|
|
||||||
assert.Equal(t, false, config.GZIP)
|
assert.Equal(t, false, config.GZIP)
|
||||||
assert.Equal(t, 2*time.Second, config.ReadHeaderTimeout)
|
assert.Equal(t, 2*time.Second, config.ReadHeaderTimeout)
|
||||||
assert.Equal(t, 10*time.Second, config.WriteTimeout)
|
assert.Equal(t, 10*time.Second, config.WriteTimeout)
|
||||||
@@ -41,7 +39,6 @@ func Test_ConfigFromEnv(t *testing.T) {
|
|||||||
config, err := hws.ConfigFromEnv()
|
config, err := hws.ConfigFromEnv()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "192.168.1.1", config.Host)
|
assert.Equal(t, "192.168.1.1", config.Host)
|
||||||
assert.Equal(t, "192.168.1.1", config.TrustedHost) // Should match host by default
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Custom port", func(t *testing.T) {
|
t.Run("Custom port", func(t *testing.T) {
|
||||||
@@ -53,18 +50,6 @@ func Test_ConfigFromEnv(t *testing.T) {
|
|||||||
assert.Equal(t, uint64(8080), config.Port)
|
assert.Equal(t, uint64(8080), config.Port)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Custom trusted host", func(t *testing.T) {
|
|
||||||
os.Setenv("HWS_HOST", "127.0.0.1")
|
|
||||||
os.Setenv("HWS_TRUSTED_HOST", "example.com")
|
|
||||||
defer os.Unsetenv("HWS_HOST")
|
|
||||||
defer os.Unsetenv("HWS_TRUSTED_HOST")
|
|
||||||
|
|
||||||
config, err := hws.ConfigFromEnv()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "127.0.0.1", config.Host)
|
|
||||||
assert.Equal(t, "example.com", config.TrustedHost)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("GZIP enabled", func(t *testing.T) {
|
t.Run("GZIP enabled", func(t *testing.T) {
|
||||||
os.Setenv("HWS_GZIP", "true")
|
os.Setenv("HWS_GZIP", "true")
|
||||||
defer os.Unsetenv("HWS_GZIP")
|
defer os.Unsetenv("HWS_GZIP")
|
||||||
@@ -92,7 +77,6 @@ func Test_ConfigFromEnv(t *testing.T) {
|
|||||||
t.Run("All custom values", func(t *testing.T) {
|
t.Run("All custom values", func(t *testing.T) {
|
||||||
os.Setenv("HWS_HOST", "0.0.0.0")
|
os.Setenv("HWS_HOST", "0.0.0.0")
|
||||||
os.Setenv("HWS_PORT", "9000")
|
os.Setenv("HWS_PORT", "9000")
|
||||||
os.Setenv("HWS_TRUSTED_HOST", "myapp.com")
|
|
||||||
os.Setenv("HWS_GZIP", "true")
|
os.Setenv("HWS_GZIP", "true")
|
||||||
os.Setenv("HWS_READ_HEADER_TIMEOUT", "3")
|
os.Setenv("HWS_READ_HEADER_TIMEOUT", "3")
|
||||||
os.Setenv("HWS_WRITE_TIMEOUT", "15")
|
os.Setenv("HWS_WRITE_TIMEOUT", "15")
|
||||||
@@ -100,7 +84,6 @@ func Test_ConfigFromEnv(t *testing.T) {
|
|||||||
defer func() {
|
defer func() {
|
||||||
os.Unsetenv("HWS_HOST")
|
os.Unsetenv("HWS_HOST")
|
||||||
os.Unsetenv("HWS_PORT")
|
os.Unsetenv("HWS_PORT")
|
||||||
os.Unsetenv("HWS_TRUSTED_HOST")
|
|
||||||
os.Unsetenv("HWS_GZIP")
|
os.Unsetenv("HWS_GZIP")
|
||||||
os.Unsetenv("HWS_READ_HEADER_TIMEOUT")
|
os.Unsetenv("HWS_READ_HEADER_TIMEOUT")
|
||||||
os.Unsetenv("HWS_WRITE_TIMEOUT")
|
os.Unsetenv("HWS_WRITE_TIMEOUT")
|
||||||
@@ -111,7 +94,6 @@ func Test_ConfigFromEnv(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "0.0.0.0", config.Host)
|
assert.Equal(t, "0.0.0.0", config.Host)
|
||||||
assert.Equal(t, uint64(9000), config.Port)
|
assert.Equal(t, uint64(9000), config.Port)
|
||||||
assert.Equal(t, "myapp.com", config.TrustedHost)
|
|
||||||
assert.Equal(t, true, config.GZIP)
|
assert.Equal(t, true, config.GZIP)
|
||||||
assert.Equal(t, 3*time.Second, config.ReadHeaderTimeout)
|
assert.Equal(t, 3*time.Second, config.ReadHeaderTimeout)
|
||||||
assert.Equal(t, 15*time.Second, config.WriteTimeout)
|
assert.Equal(t, 15*time.Second, config.WriteTimeout)
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ func Test_Start_Errors(t *testing.T) {
|
|||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = server.Start(nil)
|
err = server.Start(t.Context())
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "Context cannot be nil")
|
assert.Contains(t, err.Error(), "Context cannot be nil")
|
||||||
})
|
})
|
||||||
@@ -163,7 +163,7 @@ func Test_Shutdown_Errors(t *testing.T) {
|
|||||||
startTestServer(t, server)
|
startTestServer(t, server)
|
||||||
<-server.Ready()
|
<-server.Ready()
|
||||||
|
|
||||||
err := server.Shutdown(nil)
|
err := server.Shutdown(t.Context())
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "Context cannot be nil")
|
assert.Contains(t, err.Error(), "Context cannot be nil")
|
||||||
|
|
||||||
|
|||||||
21
hwsauth/LICENSE.md
Normal file
21
hwsauth/LICENSE.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 haelnorr
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
141
hwsauth/README.md
Normal file
141
hwsauth/README.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# HWSAuth
|
||||||
|
|
||||||
|
[](https://pkg.go.dev/git.haelnorr.com/h/golib/hwsauth)
|
||||||
|
|
||||||
|
JWT-based authentication middleware for the [HWS](https://git.haelnorr.com/h/golib/hws) web framework.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🔐 JWT-based authentication with access and refresh tokens
|
||||||
|
- 🔄 Automatic token rotation and refresh
|
||||||
|
- 🎯 Generic over user model and transaction types
|
||||||
|
- 💾 ORM-agnostic transaction handling (works with GORM, Bun, sqlx, etc.)
|
||||||
|
- ⚙️ Environment variable configuration
|
||||||
|
- 🛡️ Middleware for protecting routes
|
||||||
|
- 🔒 SSL cookie security support
|
||||||
|
- 📦 Type-safe with Go generics
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go get git.haelnorr.com/h/golib/hwsauth
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"git.haelnorr.com/h/golib/hwsauth"
|
||||||
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
UserID int
|
||||||
|
Username string
|
||||||
|
Email string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u User) ID() int {
|
||||||
|
return u.UserID
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Load configuration from environment variables
|
||||||
|
cfg, _ := hwsauth.ConfigFromEnv()
|
||||||
|
|
||||||
|
// Create database connection
|
||||||
|
db, _ := sql.Open("postgres", "postgres://...")
|
||||||
|
|
||||||
|
// Define transaction creation
|
||||||
|
beginTx := func(ctx context.Context) (hwsauth.DBTransaction, error) {
|
||||||
|
return db.BeginTx(ctx, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define user loading function
|
||||||
|
loadUser := func(ctx context.Context, tx *sql.Tx, id int) (User, error) {
|
||||||
|
var user User
|
||||||
|
err := tx.QueryRowContext(ctx,
|
||||||
|
"SELECT id, username, email FROM users WHERE id = $1", id).
|
||||||
|
Scan(&user.UserID, &user.Username, &user.Email)
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create server
|
||||||
|
serverCfg, _ := hws.ConfigFromEnv()
|
||||||
|
server, _ := hws.NewServer(serverCfg)
|
||||||
|
|
||||||
|
// Create authenticator
|
||||||
|
auth, _ := hwsauth.NewAuthenticator[User, *sql.Tx](
|
||||||
|
cfg,
|
||||||
|
loadUser,
|
||||||
|
server,
|
||||||
|
beginTx,
|
||||||
|
logger,
|
||||||
|
errorPageFunc,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Define routes
|
||||||
|
routes := []hws.Route{
|
||||||
|
{
|
||||||
|
Path: "/dashboard",
|
||||||
|
Method: hws.MethodGET,
|
||||||
|
Handler: auth.LoginReq(http.HandlerFunc(dashboardHandler)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
server.AddRoutes(routes...)
|
||||||
|
|
||||||
|
// Add authentication middleware
|
||||||
|
server.AddMiddleware(auth.Authenticate())
|
||||||
|
|
||||||
|
// Optionally ignore public paths
|
||||||
|
auth.IgnorePaths("/", "/login", "/register", "/static")
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
ctx := context.Background()
|
||||||
|
server.Start(ctx)
|
||||||
|
|
||||||
|
<-server.Ready()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Comprehensive documentation is available in the [Wiki](https://git.haelnorr.com/h/golib/wiki/hwsauth).
|
||||||
|
|
||||||
|
### Key Topics
|
||||||
|
|
||||||
|
- [Configuration](https://git.haelnorr.com/h/golib/wiki/hwsauth#configuration)
|
||||||
|
- [User Model](https://git.haelnorr.com/h/golib/wiki/hwsauth#user-model)
|
||||||
|
- [Authentication Flow](https://git.haelnorr.com/h/golib/wiki/hwsauth#authentication-flow)
|
||||||
|
- [Login & Logout](https://git.haelnorr.com/h/golib/wiki/hwsauth#login-logout)
|
||||||
|
- [Route Protection](https://git.haelnorr.com/h/golib/wiki/hwsauth#route-protection)
|
||||||
|
- [Token Refresh](https://git.haelnorr.com/h/golib/wiki/hwsauth#token-refresh)
|
||||||
|
- [Using with ORMs](https://git.haelnorr.com/h/golib/wiki/hwsauth#using-with-orms)
|
||||||
|
- [Security Best Practices](https://git.haelnorr.com/h/golib/wiki/hwsauth#security-best-practices)
|
||||||
|
|
||||||
|
## Supported ORMs
|
||||||
|
|
||||||
|
- database/sql (standard library)
|
||||||
|
- GORM
|
||||||
|
- Bun
|
||||||
|
- sqlx
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||||
|
|
||||||
|
## Related Projects
|
||||||
|
|
||||||
|
- [hws](https://git.haelnorr.com/h/golib/hws) - The web server framework
|
||||||
|
- [jwt](https://git.haelnorr.com/h/golib/jwt) - JWT token generation and validation
|
||||||
|
|
||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Check the cookies for token strings and attempt to authenticate them
|
// Check the cookies for token strings and attempt to authenticate them
|
||||||
func (auth *Authenticator[T]) getAuthenticatedUser(
|
func (auth *Authenticator[T, TX]) getAuthenticatedUser(
|
||||||
tx DBTransaction,
|
tx TX,
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
) (authenticatedModel[T], error) {
|
) (authenticatedModel[T], error) {
|
||||||
@@ -20,10 +20,10 @@ func (auth *Authenticator[T]) getAuthenticatedUser(
|
|||||||
return authenticatedModel[T]{}, errors.New("No token strings provided")
|
return authenticatedModel[T]{}, errors.New("No token strings provided")
|
||||||
}
|
}
|
||||||
// Attempt to parse the access token
|
// Attempt to parse the access token
|
||||||
aT, err := auth.tokenGenerator.ValidateAccess(tx, atStr)
|
aT, err := auth.tokenGenerator.ValidateAccess(jwt.DBTransaction(tx), atStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Access token invalid, attempt to parse refresh token
|
// Access token invalid, attempt to parse refresh token
|
||||||
rT, err := auth.tokenGenerator.ValidateRefresh(tx, rtStr)
|
rT, err := auth.tokenGenerator.ValidateRefresh(jwt.DBTransaction(tx), rtStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return authenticatedModel[T]{}, errors.Wrap(err, "auth.tokenGenerator.ValidateRefresh")
|
return authenticatedModel[T]{}, errors.Wrap(err, "auth.tokenGenerator.ValidateRefresh")
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@ func (auth *Authenticator[T]) getAuthenticatedUser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Access token valid
|
// Access token valid
|
||||||
model, err := auth.load(tx, aT.SUB)
|
model, err := auth.load(r.Context(), tx, aT.SUB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return authenticatedModel[T]{}, errors.Wrap(err, "auth.load")
|
return authenticatedModel[T]{}, errors.Wrap(err, "auth.load")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
package hwsauth
|
package hwsauth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
|
||||||
|
|
||||||
"git.haelnorr.com/h/golib/hws"
|
"git.haelnorr.com/h/golib/hws"
|
||||||
"git.haelnorr.com/h/golib/jwt"
|
"git.haelnorr.com/h/golib/jwt"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Authenticator[T Model] struct {
|
type Authenticator[T Model, TX DBTransaction] struct {
|
||||||
tokenGenerator *jwt.TokenGenerator
|
tokenGenerator *jwt.TokenGenerator
|
||||||
load LoadFunc[T]
|
load LoadFunc[T, TX]
|
||||||
conn DBConnection
|
beginTx BeginTX
|
||||||
ignoredPaths []string
|
ignoredPaths []string
|
||||||
logger *zerolog.Logger
|
logger *zerolog.Logger
|
||||||
server *hws.Server
|
server *hws.Server
|
||||||
@@ -25,22 +23,22 @@ type Authenticator[T Model] struct {
|
|||||||
// If cfg is nil or any required fields are not set, default values will be used or an error returned.
|
// If cfg is nil or any required fields are not set, default values will be used or an error returned.
|
||||||
// Required fields: SecretKey (no default)
|
// Required fields: SecretKey (no default)
|
||||||
// If SSL is true, TrustedHost is also required.
|
// If SSL is true, TrustedHost is also required.
|
||||||
func NewAuthenticator[T Model](
|
func NewAuthenticator[T Model, TX DBTransaction](
|
||||||
cfg *Config,
|
cfg *Config,
|
||||||
load LoadFunc[T],
|
load LoadFunc[T, TX],
|
||||||
server *hws.Server,
|
server *hws.Server,
|
||||||
conn DBConnection,
|
beginTx BeginTX,
|
||||||
logger *zerolog.Logger,
|
logger *zerolog.Logger,
|
||||||
errorPage hws.ErrorPageFunc,
|
errorPage hws.ErrorPageFunc,
|
||||||
) (*Authenticator[T], error) {
|
) (*Authenticator[T, TX], error) {
|
||||||
if load == nil {
|
if load == nil {
|
||||||
return nil, errors.New("No function to load model supplied")
|
return nil, errors.New("No function to load model supplied")
|
||||||
}
|
}
|
||||||
if server == nil {
|
if server == nil {
|
||||||
return nil, errors.New("No hws.Server provided")
|
return nil, errors.New("No hws.Server provided")
|
||||||
}
|
}
|
||||||
if conn == nil {
|
if beginTx == nil {
|
||||||
return nil, errors.New("No database connection supplied")
|
return nil, errors.New("No beginTx function provided")
|
||||||
}
|
}
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
return nil, errors.New("No logger provided")
|
return nil, errors.New("No logger provided")
|
||||||
@@ -72,13 +70,6 @@ func NewAuthenticator[T Model](
|
|||||||
cfg.LandingPage = "/profile"
|
cfg.LandingPage = "/profile"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cast DBConnection to *sql.DB
|
|
||||||
// DBConnection is satisfied by *sql.DB, so this cast should be safe for standard usage
|
|
||||||
sqlDB, ok := conn.(*sql.DB)
|
|
||||||
if !ok {
|
|
||||||
return nil, errors.New("DBConnection must be *sql.DB for JWT token generation")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure JWT table
|
// Configure JWT table
|
||||||
tableConfig := jwt.DefaultTableConfig()
|
tableConfig := jwt.DefaultTableConfig()
|
||||||
if cfg.JWTTableName != "" {
|
if cfg.JWTTableName != "" {
|
||||||
@@ -92,22 +83,21 @@ func NewAuthenticator[T Model](
|
|||||||
FreshExpireAfter: cfg.TokenFreshTime,
|
FreshExpireAfter: cfg.TokenFreshTime,
|
||||||
TrustedHost: cfg.TrustedHost,
|
TrustedHost: cfg.TrustedHost,
|
||||||
SecretKey: cfg.SecretKey,
|
SecretKey: cfg.SecretKey,
|
||||||
DBConn: sqlDB,
|
|
||||||
DBType: jwt.DatabaseType{
|
DBType: jwt.DatabaseType{
|
||||||
Type: cfg.DatabaseType,
|
Type: cfg.DatabaseType,
|
||||||
Version: cfg.DatabaseVersion,
|
Version: cfg.DatabaseVersion,
|
||||||
},
|
},
|
||||||
TableConfig: tableConfig,
|
TableConfig: tableConfig,
|
||||||
})
|
}, beginTx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "jwt.CreateGenerator")
|
return nil, errors.Wrap(err, "jwt.CreateGenerator")
|
||||||
}
|
}
|
||||||
|
|
||||||
auth := Authenticator[T]{
|
auth := Authenticator[T, TX]{
|
||||||
tokenGenerator: tokenGen,
|
tokenGenerator: tokenGen,
|
||||||
load: load,
|
load: load,
|
||||||
server: server,
|
server: server,
|
||||||
conn: conn,
|
beginTx: beginTx,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
errorPage: errorPage,
|
errorPage: errorPage,
|
||||||
SSL: cfg.SSL,
|
SSL: cfg.SSL,
|
||||||
|
|||||||
@@ -6,22 +6,31 @@ import (
|
|||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Config holds the configuration settings for the authenticator.
|
||||||
|
// All time-based settings are in minutes.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
SSL bool // ENV HWSAUTH_SSL: Flag for SSL Mode (default: false)
|
SSL bool // ENV HWSAUTH_SSL: Enable SSL secure cookies (default: false)
|
||||||
TrustedHost string // ENV HWSAUTH_TRUSTED_HOST: Full server address to accept as trusted SSL host (required if SSL is true)
|
TrustedHost string // ENV HWSAUTH_TRUSTED_HOST: Full server address for SSL (required if SSL is true)
|
||||||
SecretKey string // ENV HWSAUTH_SECRET_KEY: Secret key for signing tokens (required)
|
SecretKey string // ENV HWSAUTH_SECRET_KEY: Secret key for signing JWT tokens (required)
|
||||||
AccessTokenExpiry int64 // ENV HWSAUTH_ACCESS_TOKEN_EXPIRY: Access token expiry in minutes (default: 5)
|
AccessTokenExpiry int64 // ENV HWSAUTH_ACCESS_TOKEN_EXPIRY: Access token expiry in minutes (default: 5)
|
||||||
RefreshTokenExpiry int64 // ENV HWSAUTH_REFRESH_TOKEN_EXPIRY: Refresh token expiry in minutes (default: 1440)
|
RefreshTokenExpiry int64 // ENV HWSAUTH_REFRESH_TOKEN_EXPIRY: Refresh token expiry in minutes (default: 1440)
|
||||||
TokenFreshTime int64 // ENV HWSAUTH_TOKEN_FRESH_TIME: Time for tokens to stay fresh in minutes (default: 5)
|
TokenFreshTime int64 // ENV HWSAUTH_TOKEN_FRESH_TIME: Token fresh time in minutes (default: 5)
|
||||||
LandingPage string // ENV HWSAUTH_LANDING_PAGE: Path of the desired landing page for logged in users (default: "/profile")
|
LandingPage string // ENV HWSAUTH_LANDING_PAGE: Redirect destination for authenticated users (default: "/profile")
|
||||||
DatabaseType string // ENV HWSAUTH_DATABASE_TYPE: Database type (postgres, mysql, sqlite, mariadb) (default: "postgres")
|
DatabaseType string // ENV HWSAUTH_DATABASE_TYPE: Database type (postgres, mysql, sqlite, mariadb) (default: "postgres")
|
||||||
DatabaseVersion string // ENV HWSAUTH_DATABASE_VERSION: Database version (default: "15")
|
DatabaseVersion string // ENV HWSAUTH_DATABASE_VERSION: Database version string (default: "15")
|
||||||
JWTTableName string // ENV HWSAUTH_JWT_TABLE_NAME: JWT blacklist table name (default: "jwtblacklist")
|
JWTTableName string // ENV HWSAUTH_JWT_TABLE_NAME: Custom JWT blacklist table name (default: "jwtblacklist")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConfigFromEnv loads configuration from environment variables.
|
||||||
|
//
|
||||||
|
// Required environment variables:
|
||||||
|
// - HWSAUTH_SECRET_KEY: Secret key for JWT signing
|
||||||
|
// - HWSAUTH_TRUSTED_HOST: Required if HWSAUTH_SSL is true
|
||||||
|
//
|
||||||
|
// Returns an error if required variables are missing or invalid.
|
||||||
func ConfigFromEnv() (*Config, error) {
|
func ConfigFromEnv() (*Config, error) {
|
||||||
ssl := env.Bool("HWSAUTH_SSL", false)
|
ssl := env.Bool("HWSAUTH_SSL", false)
|
||||||
trustedHost := env.String("HWS_TRUSTED_HOST", "")
|
trustedHost := env.String("HWSAUTH_TRUSTED_HOST", "")
|
||||||
if ssl && trustedHost == "" {
|
if ssl && trustedHost == "" {
|
||||||
return nil, errors.New("SSL is enabled and no HWS_TRUSTED_HOST set")
|
return nil, errors.New("SSL is enabled and no HWS_TRUSTED_HOST set")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,22 @@
|
|||||||
package hwsauth
|
package hwsauth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"git.haelnorr.com/h/golib/jwt"
|
||||||
"database/sql"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// DBTransaction represents a database transaction that can be committed or rolled back.
|
// DBTransaction represents a database transaction that can be committed or rolled back.
|
||||||
// This interface can be implemented by standard library sql.Tx, or by ORM transactions
|
// This is an alias to jwt.DBTransaction.
|
||||||
// from libraries like bun, gorm, sqlx, etc.
|
//
|
||||||
type DBTransaction interface {
|
// Standard library *sql.Tx implements this interface automatically.
|
||||||
Commit() error
|
// ORM transactions (GORM, Bun, etc.) should also implement this interface.
|
||||||
Rollback() error
|
type DBTransaction = jwt.DBTransaction
|
||||||
}
|
|
||||||
|
|
||||||
// DBConnection represents a database connection that can begin transactions.
|
// BeginTX is a function type for creating database transactions.
|
||||||
// This interface can be implemented by standard library sql.DB, or by ORM connections
|
// This is an alias to jwt.BeginTX.
|
||||||
// from libraries like bun, gorm, sqlx, etc.
|
//
|
||||||
type DBConnection interface {
|
// Example:
|
||||||
BeginTx(ctx context.Context, opts *sql.TxOptions) (DBTransaction, error)
|
//
|
||||||
}
|
// beginTx := func(ctx context.Context) (hwsauth.DBTransaction, error) {
|
||||||
|
// return db.BeginTx(ctx, nil)
|
||||||
// Ensure *sql.Tx implements DBTransaction
|
// }
|
||||||
var _ DBTransaction = (*sql.Tx)(nil)
|
type BeginTX = jwt.BeginTX
|
||||||
|
|
||||||
// Ensure *sql.DB implements DBConnection
|
|
||||||
var _ DBConnection = (*sql.DB)(nil)
|
|
||||||
|
|||||||
212
hwsauth/doc.go
Normal file
212
hwsauth/doc.go
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
// Package hwsauth provides JWT-based authentication middleware for the hws web framework.
|
||||||
|
//
|
||||||
|
// # Overview
|
||||||
|
//
|
||||||
|
// hwsauth integrates with the hws web server to provide secure, stateless authentication
|
||||||
|
// using JSON Web Tokens (JWT). It supports both access and refresh tokens, automatic
|
||||||
|
// token rotation, and flexible transaction handling compatible with any database or ORM.
|
||||||
|
//
|
||||||
|
// # Key Features
|
||||||
|
//
|
||||||
|
// - JWT-based authentication with access and refresh tokens
|
||||||
|
// - Automatic token rotation and refresh
|
||||||
|
// - Generic over user model and transaction types
|
||||||
|
// - ORM-agnostic transaction handling
|
||||||
|
// - Environment variable configuration
|
||||||
|
// - Middleware for protecting routes
|
||||||
|
// - Context-based user retrieval
|
||||||
|
// - Optional SSL cookie security
|
||||||
|
//
|
||||||
|
// # Quick Start
|
||||||
|
//
|
||||||
|
// First, define your user model:
|
||||||
|
//
|
||||||
|
// type User struct {
|
||||||
|
// UserID int
|
||||||
|
// Username string
|
||||||
|
// Email string
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func (u User) ID() int {
|
||||||
|
// return u.UserID
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Configure the authenticator using environment variables or programmatically:
|
||||||
|
//
|
||||||
|
// // Option 1: Load from environment variables
|
||||||
|
// cfg, err := hwsauth.ConfigFromEnv()
|
||||||
|
// if err != nil {
|
||||||
|
// log.Fatal(err)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Option 2: Create config manually
|
||||||
|
// cfg := &hwsauth.Config{
|
||||||
|
// SSL: true,
|
||||||
|
// TrustedHost: "https://example.com",
|
||||||
|
// SecretKey: "your-secret-key",
|
||||||
|
// AccessTokenExpiry: 5, // 5 minutes
|
||||||
|
// RefreshTokenExpiry: 1440, // 1 day
|
||||||
|
// TokenFreshTime: 5, // 5 minutes
|
||||||
|
// LandingPage: "/dashboard",
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Create the authenticator:
|
||||||
|
//
|
||||||
|
// // Define how to begin transactions
|
||||||
|
// beginTx := func(ctx context.Context) (hwsauth.DBTransaction, error) {
|
||||||
|
// return db.BeginTx(ctx, nil)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Define how to load users from the database
|
||||||
|
// loadUser := func(ctx context.Context, tx *sql.Tx, id int) (User, error) {
|
||||||
|
// var user User
|
||||||
|
// err := tx.QueryRowContext(ctx, "SELECT id, username, email FROM users WHERE id = ?", id).
|
||||||
|
// Scan(&user.UserID, &user.Username, &user.Email)
|
||||||
|
// return user, err
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Create the authenticator
|
||||||
|
// auth, err := hwsauth.NewAuthenticator[User, *sql.Tx](
|
||||||
|
// cfg,
|
||||||
|
// loadUser,
|
||||||
|
// server,
|
||||||
|
// beginTx,
|
||||||
|
// logger,
|
||||||
|
// errorPage,
|
||||||
|
// )
|
||||||
|
// if err != nil {
|
||||||
|
// log.Fatal(err)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// # Middleware
|
||||||
|
//
|
||||||
|
// Use the Authenticate middleware to protect routes:
|
||||||
|
//
|
||||||
|
// // Apply to all routes
|
||||||
|
// server.AddMiddleware(auth.Authenticate())
|
||||||
|
//
|
||||||
|
// // Ignore specific paths
|
||||||
|
// auth.IgnorePaths("/login", "/register", "/public")
|
||||||
|
//
|
||||||
|
// Use route guards for specific protection requirements:
|
||||||
|
//
|
||||||
|
// // LoginReq: Requires user to be authenticated
|
||||||
|
// protectedHandler := auth.LoginReq(myHandler)
|
||||||
|
//
|
||||||
|
// // LogoutReq: Redirects authenticated users (for login/register pages)
|
||||||
|
// loginHandler := auth.LogoutReq(loginPageHandler)
|
||||||
|
//
|
||||||
|
// // FreshReq: Requires fresh authentication (for sensitive operations)
|
||||||
|
// changePasswordHandler := auth.FreshReq(changePasswordHandler)
|
||||||
|
//
|
||||||
|
// # Login and Logout
|
||||||
|
//
|
||||||
|
// To log a user in:
|
||||||
|
//
|
||||||
|
// func loginHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// // Validate credentials...
|
||||||
|
// user := getUserFromDatabase(username)
|
||||||
|
//
|
||||||
|
// // Log the user in (sets JWT cookies)
|
||||||
|
// err := auth.Login(w, r, user, rememberMe)
|
||||||
|
// if err != nil {
|
||||||
|
// // Handle error
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// To log a user out:
|
||||||
|
//
|
||||||
|
// func logoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// tx, _ := db.BeginTx(r.Context(), nil)
|
||||||
|
// defer tx.Rollback()
|
||||||
|
//
|
||||||
|
// err := auth.Logout(tx, w, r)
|
||||||
|
// if err != nil {
|
||||||
|
// // Handle error
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// tx.Commit()
|
||||||
|
// http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// # Retrieving the Current User
|
||||||
|
//
|
||||||
|
// Access the authenticated user from the request context:
|
||||||
|
//
|
||||||
|
// func dashboardHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// user := auth.CurrentModel(r.Context())
|
||||||
|
// if user.ID() == 0 {
|
||||||
|
// // User not authenticated
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// fmt.Fprintf(w, "Welcome, %s!", user.Username)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// # ORM Support
|
||||||
|
//
|
||||||
|
// hwsauth works with any ORM that implements the DBTransaction interface.
|
||||||
|
//
|
||||||
|
// GORM Example:
|
||||||
|
//
|
||||||
|
// beginTx := func(ctx context.Context) (hwsauth.DBTransaction, error) {
|
||||||
|
// return gormDB.WithContext(ctx).Begin().Statement.ConnPool.(*sql.Tx), nil
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// loadUser := func(ctx context.Context, tx *gorm.DB, id int) (User, error) {
|
||||||
|
// var user User
|
||||||
|
// err := tx.First(&user, id).Error
|
||||||
|
// return user, err
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// auth, err := hwsauth.NewAuthenticator[User, *gorm.DB](...)
|
||||||
|
//
|
||||||
|
// Bun Example:
|
||||||
|
//
|
||||||
|
// beginTx := func(ctx context.Context) (hwsauth.DBTransaction, error) {
|
||||||
|
// return bunDB.BeginTx(ctx, nil)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// loadUser := func(ctx context.Context, tx bun.Tx, id int) (User, error) {
|
||||||
|
// var user User
|
||||||
|
// err := tx.NewSelect().Model(&user).Where("id = ?", id).Scan(ctx)
|
||||||
|
// return user, err
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// auth, err := hwsauth.NewAuthenticator[User, bun.Tx](...)
|
||||||
|
//
|
||||||
|
// # Environment Variables
|
||||||
|
//
|
||||||
|
// The following environment variables are supported:
|
||||||
|
//
|
||||||
|
// - HWSAUTH_SSL: Enable SSL mode (default: false)
|
||||||
|
// - HWSAUTH_TRUSTED_HOST: Trusted host for SSL (required if SSL is true)
|
||||||
|
// - HWSAUTH_SECRET_KEY: Secret key for signing tokens (required)
|
||||||
|
// - HWSAUTH_ACCESS_TOKEN_EXPIRY: Access token expiry in minutes (default: 5)
|
||||||
|
// - HWSAUTH_REFRESH_TOKEN_EXPIRY: Refresh token expiry in minutes (default: 1440)
|
||||||
|
// - HWSAUTH_TOKEN_FRESH_TIME: Token fresh time in minutes (default: 5)
|
||||||
|
// - HWSAUTH_LANDING_PAGE: Landing page for logged in users (default: "/profile")
|
||||||
|
// - HWSAUTH_JWT_TABLE_NAME: Custom JWT table name (optional)
|
||||||
|
// - HWSAUTH_DATABASE_TYPE: Database type (e.g., "postgres", "mysql")
|
||||||
|
// - HWSAUTH_DATABASE_VERSION: Database version (e.g., "15")
|
||||||
|
//
|
||||||
|
// # Security Considerations
|
||||||
|
//
|
||||||
|
// - Always use SSL in production (set HWSAUTH_SSL=true)
|
||||||
|
// - Use strong, randomly generated secret keys
|
||||||
|
// - Set appropriate token expiry times based on your security requirements
|
||||||
|
// - Use FreshReq middleware for sensitive operations (password changes, etc.)
|
||||||
|
// - Store refresh tokens securely in HTTP-only cookies
|
||||||
|
//
|
||||||
|
// # Type Parameters
|
||||||
|
//
|
||||||
|
// hwsauth uses Go generics for type safety:
|
||||||
|
//
|
||||||
|
// - T Model: Your user model type (must implement the Model interface)
|
||||||
|
// - TX DBTransaction: Your transaction type (must implement DBTransaction interface)
|
||||||
|
//
|
||||||
|
// This allows compile-time type checking and eliminates the need for type assertions
|
||||||
|
// when working with your user models.
|
||||||
|
package hwsauth
|
||||||
@@ -5,23 +5,21 @@ go 1.25.5
|
|||||||
require (
|
require (
|
||||||
git.haelnorr.com/h/golib/cookies v0.9.0
|
git.haelnorr.com/h/golib/cookies v0.9.0
|
||||||
git.haelnorr.com/h/golib/env v0.9.1
|
git.haelnorr.com/h/golib/env v0.9.1
|
||||||
git.haelnorr.com/h/golib/hws v0.1.0
|
git.haelnorr.com/h/golib/hws v0.2.0
|
||||||
git.haelnorr.com/h/golib/jwt v0.9.2
|
git.haelnorr.com/h/golib/jwt v0.10.0
|
||||||
github.com/pkg/errors v0.9.1
|
github.com/pkg/errors v0.9.1
|
||||||
github.com/rs/zerolog v1.34.0
|
github.com/rs/zerolog v1.34.0
|
||||||
)
|
)
|
||||||
|
|
||||||
replace git.haelnorr.com/h/golib/hws => ../hws
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.haelnorr.com/h/golib/hlog v0.9.0 // indirect
|
git.haelnorr.com/h/golib/hlog v0.9.1 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
golang.org/x/sys v0.12.0 // indirect
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
k8s.io/apimachinery v0.35.0 // indirect
|
k8s.io/apimachinery v0.35.0 // indirect
|
||||||
k8s.io/klog/v2 v2.130.1 // indirect
|
k8s.io/klog/v2 v2.130.1 // indirect
|
||||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
|
k8s.io/utils v0.0.0-20260108192941-914a6e750570 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ git.haelnorr.com/h/golib/cookies v0.9.0 h1:Vf+eX1prHkKuGrQon1BHY87yaPc1H+HJFRXDO
|
|||||||
git.haelnorr.com/h/golib/cookies v0.9.0/go.mod h1:y1385YExI9gLwckCVDCYVcsFXr6N7T3brJjnJD2QIuo=
|
git.haelnorr.com/h/golib/cookies v0.9.0/go.mod h1:y1385YExI9gLwckCVDCYVcsFXr6N7T3brJjnJD2QIuo=
|
||||||
git.haelnorr.com/h/golib/env v0.9.1 h1:2Vsj+mJKnO5f1Md1GO5v9ggLN5zWa0baCewcSHTjoNY=
|
git.haelnorr.com/h/golib/env v0.9.1 h1:2Vsj+mJKnO5f1Md1GO5v9ggLN5zWa0baCewcSHTjoNY=
|
||||||
git.haelnorr.com/h/golib/env v0.9.1/go.mod h1:glUQVdA1HMKX1avTDyTyuhcr36SSxZtlJxKDT5KTztg=
|
git.haelnorr.com/h/golib/env v0.9.1/go.mod h1:glUQVdA1HMKX1avTDyTyuhcr36SSxZtlJxKDT5KTztg=
|
||||||
git.haelnorr.com/h/golib/hlog v0.9.0 h1:ib8n2MdmiRK2TF067p220kXmhDe9aAnlcsgpuv+QpvE=
|
git.haelnorr.com/h/golib/hlog v0.9.1 h1:9VmE/IQTfD8LAEyTbUCZLy/+8PbcHA1Kob/WQHRHKzc=
|
||||||
git.haelnorr.com/h/golib/hlog v0.9.0/go.mod h1:oOlzb8UVHUYP1k7dN5PSJXVskAB2z8EYgRN85jAi0Zk=
|
git.haelnorr.com/h/golib/hlog v0.9.1/go.mod h1:oOlzb8UVHUYP1k7dN5PSJXVskAB2z8EYgRN85jAi0Zk=
|
||||||
git.haelnorr.com/h/golib/jwt v0.9.2 h1:l1Ow7DPGACAU54CnMP/NlZjdc4nRD1wr3xZ8a7taRvU=
|
git.haelnorr.com/h/golib/hws v0.2.0 h1:MR2Tu2qPaW+/oK8aXFJLRFaYZIHgKiex3t3zE41cu1U=
|
||||||
git.haelnorr.com/h/golib/jwt v0.9.2/go.mod h1:fbuPrfucT9lL0faV5+Q5Gk9WFJxPlwzRPpbMQKYZok4=
|
git.haelnorr.com/h/golib/hws v0.2.0/go.mod h1:6ZlRKnt8YMpv5XcMXmyBGmD1/euvBo3d1azEvHJjOLo=
|
||||||
|
git.haelnorr.com/h/golib/jwt v0.10.0 h1:8cI8mSnb8X+EmJtrBO/5UZwuBMtib0IE9dv85gkm94E=
|
||||||
|
git.haelnorr.com/h/golib/jwt v0.10.0/go.mod h1:fbuPrfucT9lL0faV5+Q5Gk9WFJxPlwzRPpbMQKYZok4=
|
||||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
@@ -18,11 +20,13 @@ github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keL
|
|||||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
|
||||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
@@ -34,13 +38,14 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
|
|
||||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8=
|
k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8=
|
||||||
k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
|
k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
|
||||||
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
||||||
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
||||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
|
k8s.io/utils v0.0.0-20260108192941-914a6e750570 h1:JT4W8lsdrGENg9W+YwwdLJxklIuKWdRm+BC+xt33FOY=
|
||||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
k8s.io/utils v0.0.0-20260108192941-914a6e750570/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
|
||||||
|
|||||||
@@ -5,7 +5,15 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (auth *Authenticator[T]) IgnorePaths(paths ...string) error {
|
// IgnorePaths excludes specified paths from authentication middleware.
|
||||||
|
// Paths must be valid URL paths (relative paths without scheme or host).
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// auth.IgnorePaths("/", "/login", "/register", "/public", "/static")
|
||||||
|
//
|
||||||
|
// Returns an error if any path is invalid.
|
||||||
|
func (auth *Authenticator[T, TX]) IgnorePaths(paths ...string) error {
|
||||||
for _, path := range paths {
|
for _, path := range paths {
|
||||||
u, err := url.Parse(path)
|
u, err := url.Parse(path)
|
||||||
valid := err == nil &&
|
valid := err == nil &&
|
||||||
|
|||||||
@@ -7,14 +7,38 @@ import (
|
|||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (auth *Authenticator[T]) Login(
|
// Login authenticates a user and sets JWT tokens as HTTP-only cookies.
|
||||||
|
// The rememberMe parameter determines token expiration behavior.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - w: HTTP response writer for setting cookies
|
||||||
|
// - r: HTTP request
|
||||||
|
// - model: The authenticated user model
|
||||||
|
// - rememberMe: If true, tokens have extended expiry; if false, session-based
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// func loginHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// user, err := validateCredentials(username, password)
|
||||||
|
// if err != nil {
|
||||||
|
// http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
// err = auth.Login(w, r, user, true)
|
||||||
|
// if err != nil {
|
||||||
|
// http.Error(w, "Login failed", http.StatusInternalServerError)
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
// http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
||||||
|
// }
|
||||||
|
func (auth *Authenticator[T, TX]) Login(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
model T,
|
model T,
|
||||||
rememberMe bool,
|
rememberMe bool,
|
||||||
) error {
|
) error {
|
||||||
|
|
||||||
err := jwt.SetTokenCookies(w, r, auth.tokenGenerator, model.ID(), true, rememberMe, auth.SSL)
|
err := jwt.SetTokenCookies(w, r, auth.tokenGenerator, model.GetID(), true, rememberMe, auth.SSL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "jwt.SetTokenCookies")
|
return errors.Wrap(err, "jwt.SetTokenCookies")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,19 +4,40 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.haelnorr.com/h/golib/cookies"
|
"git.haelnorr.com/h/golib/cookies"
|
||||||
|
"git.haelnorr.com/h/golib/jwt"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (auth *Authenticator[T]) Logout(tx DBTransaction, w http.ResponseWriter, r *http.Request) error {
|
// Logout revokes the user's authentication tokens and clears their cookies.
|
||||||
|
// This operation requires a database transaction to revoke tokens.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - tx: Database transaction for revoking tokens
|
||||||
|
// - w: HTTP response writer for clearing cookies
|
||||||
|
// - r: HTTP request containing the tokens to revoke
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// func logoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// tx, _ := db.BeginTx(r.Context(), nil)
|
||||||
|
// defer tx.Rollback()
|
||||||
|
// if err := auth.Logout(tx, w, r); err != nil {
|
||||||
|
// http.Error(w, "Logout failed", http.StatusInternalServerError)
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
// tx.Commit()
|
||||||
|
// http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
// }
|
||||||
|
func (auth *Authenticator[T, TX]) Logout(tx TX, w http.ResponseWriter, r *http.Request) error {
|
||||||
aT, rT, err := auth.getTokens(tx, r)
|
aT, rT, err := auth.getTokens(tx, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "auth.getTokens")
|
return errors.Wrap(err, "auth.getTokens")
|
||||||
}
|
}
|
||||||
err = aT.Revoke(tx)
|
err = aT.Revoke(jwt.DBTransaction(tx))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "aT.Revoke")
|
return errors.Wrap(err, "aT.Revoke")
|
||||||
}
|
}
|
||||||
err = rT.Revoke(tx)
|
err = rT.Revoke(jwt.DBTransaction(tx))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "rT.Revoke")
|
return errors.Wrap(err, "rT.Revoke")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,18 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (auth *Authenticator[T]) Authenticate() hws.Middleware {
|
// Authenticate returns the main authentication middleware.
|
||||||
|
// This middleware validates JWT tokens, refreshes expired tokens, and adds
|
||||||
|
// the authenticated user to the request context.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// server.AddMiddleware(auth.Authenticate())
|
||||||
|
func (auth *Authenticator[T, TX]) Authenticate() hws.Middleware {
|
||||||
return auth.server.NewMiddleware(auth.authenticate())
|
return auth.server.NewMiddleware(auth.authenticate())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *Authenticator[T]) authenticate() hws.MiddlewareFunc {
|
func (auth *Authenticator[T, TX]) authenticate() hws.MiddlewareFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) (*http.Request, *hws.HWSError) {
|
return func(w http.ResponseWriter, r *http.Request) (*http.Request, *hws.HWSError) {
|
||||||
if slices.Contains(auth.ignoredPaths, r.URL.Path) {
|
if slices.Contains(auth.ignoredPaths, r.URL.Path) {
|
||||||
return r, nil
|
return r, nil
|
||||||
@@ -21,11 +28,16 @@ func (auth *Authenticator[T]) authenticate() hws.MiddlewareFunc {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Start the transaction
|
// Start the transaction
|
||||||
tx, err := auth.conn.BeginTx(ctx, nil)
|
tx, err := auth.beginTx(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, &hws.HWSError{Message: "Unable to start transaction", StatusCode: http.StatusServiceUnavailable, Error: err}
|
return nil, &hws.HWSError{Message: "Unable to start transaction", StatusCode: http.StatusServiceUnavailable, Error: err}
|
||||||
}
|
}
|
||||||
model, err := auth.getAuthenticatedUser(tx, w, r)
|
// Type assert to TX - safe because user's beginTx should return their TX type
|
||||||
|
txTyped, ok := tx.(TX)
|
||||||
|
if !ok {
|
||||||
|
return nil, &hws.HWSError{Message: "Transaction type mismatch", StatusCode: http.StatusInternalServerError, Error: err}
|
||||||
|
}
|
||||||
|
model, err := auth.getAuthenticatedUser(txTyped, w, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
auth.logger.Debug().
|
auth.logger.Debug().
|
||||||
|
|||||||
@@ -14,13 +14,30 @@ func getNil[T Model]() T {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Model represents an authenticated user model.
|
||||||
|
// User types must implement this interface to be used with the authenticator.
|
||||||
type Model interface {
|
type Model interface {
|
||||||
ID() int
|
GetID() int // Returns the unique identifier for the user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ContextLoader is a function type that loads a model from a context.
|
||||||
|
// Deprecated: Use CurrentModel method instead.
|
||||||
type ContextLoader[T Model] func(ctx context.Context) T
|
type ContextLoader[T Model] func(ctx context.Context) T
|
||||||
|
|
||||||
type LoadFunc[T Model] func(tx DBTransaction, id int) (T, error)
|
// LoadFunc is a function type that loads a user model from the database.
|
||||||
|
// It receives a context for cancellation, a transaction for database operations,
|
||||||
|
// and the user ID to load.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// loadUser := func(ctx context.Context, tx *sql.Tx, id int) (User, error) {
|
||||||
|
// var user User
|
||||||
|
// err := tx.QueryRowContext(ctx,
|
||||||
|
// "SELECT id, username, email FROM users WHERE id = $1", id).
|
||||||
|
// Scan(&user.ID, &user.Username, &user.Email)
|
||||||
|
// return user, err
|
||||||
|
// }
|
||||||
|
type LoadFunc[T Model, TX DBTransaction] func(ctx context.Context, tx TX, id int) (T, error)
|
||||||
|
|
||||||
// Return a new context with the user added in
|
// Return a new context with the user added in
|
||||||
func setAuthenticatedModel[T Model](ctx context.Context, m authenticatedModel[T]) context.Context {
|
func setAuthenticatedModel[T Model](ctx context.Context, m authenticatedModel[T]) context.Context {
|
||||||
@@ -43,15 +60,26 @@ func getAuthorizedModel[T Model](ctx context.Context) (model authenticatedModel[
|
|||||||
return model, true
|
return model, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *Authenticator[T]) CurrentModel(ctx context.Context) T {
|
// CurrentModel retrieves the authenticated user from the request context.
|
||||||
auth.logger.Debug().Any("context", ctx).Msg("")
|
// Returns a zero-value T if no user is authenticated or context is nil.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// func handler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// user := auth.CurrentModel(r.Context())
|
||||||
|
// if user.ID() == 0 {
|
||||||
|
// http.Error(w, "Not authenticated", http.StatusUnauthorized)
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
// fmt.Fprintf(w, "Hello, %s!", user.Username)
|
||||||
|
// }
|
||||||
|
func (auth *Authenticator[T, TX]) CurrentModel(ctx context.Context) T {
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
return getNil[T]()
|
return getNil[T]()
|
||||||
}
|
}
|
||||||
model, ok := getAuthorizedModel[T](ctx)
|
model, ok := getAuthorizedModel[T](ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
result := getNil[T]()
|
result := getNil[T]()
|
||||||
auth.logger.Debug().Any("model", result).Msg("")
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
return model.model
|
return model.model
|
||||||
|
|||||||
@@ -7,8 +7,14 @@ import (
|
|||||||
"git.haelnorr.com/h/golib/hws"
|
"git.haelnorr.com/h/golib/hws"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Checks if the model is set in the context and shows 401 page if not logged in
|
// LoginReq returns a middleware that requires the user to be authenticated.
|
||||||
func (auth *Authenticator[T]) LoginReq(next http.Handler) http.Handler {
|
// If the user is not authenticated, it returns a 401 Unauthorized error page.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// protectedHandler := auth.LoginReq(http.HandlerFunc(dashboardHandler))
|
||||||
|
// server.AddRoute("GET", "/dashboard", protectedHandler)
|
||||||
|
func (auth *Authenticator[T, TX]) LoginReq(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
_, ok := getAuthorizedModel[T](r.Context())
|
_, ok := getAuthorizedModel[T](r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -36,9 +42,14 @@ func (auth *Authenticator[T]) LoginReq(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checks if the model is set in the context and redirects them to the landing page if
|
// LogoutReq returns a middleware that redirects authenticated users to the landing page.
|
||||||
// they are logged in
|
// Use this for login and registration pages to prevent logged-in users from accessing them.
|
||||||
func (auth *Authenticator[T]) LogoutReq(next http.Handler) http.Handler {
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// loginPageHandler := auth.LogoutReq(http.HandlerFunc(showLoginPage))
|
||||||
|
// server.AddRoute("GET", "/login", loginPageHandler)
|
||||||
|
func (auth *Authenticator[T, TX]) LogoutReq(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
_, ok := getAuthorizedModel[T](r.Context())
|
_, ok := getAuthorizedModel[T](r.Context())
|
||||||
if ok {
|
if ok {
|
||||||
@@ -49,10 +60,17 @@ func (auth *Authenticator[T]) LogoutReq(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// FreshReq protects a route from access if the auth token is not fresh.
|
// FreshReq returns a middleware that requires a fresh authentication token.
|
||||||
// A status code of 444 will be written to the header and the request will be terminated.
|
// If the token is not fresh (recently issued), it returns a 444 status code.
|
||||||
// As an example, this can be used on the client to show a confirm password dialog to refresh their login
|
// Use this for sensitive operations like password changes or account deletions.
|
||||||
func (auth *Authenticator[T]) FreshReq(next http.Handler) http.Handler {
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// changePasswordHandler := auth.FreshReq(http.HandlerFunc(handlePasswordChange))
|
||||||
|
// server.AddRoute("POST", "/change-password", changePasswordHandler)
|
||||||
|
//
|
||||||
|
// The 444 status code can be used by the client to prompt for re-authentication.
|
||||||
|
func (auth *Authenticator[T, TX]) FreshReq(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
model, ok := getAuthorizedModel[T](r.Context())
|
model, ok := getAuthorizedModel[T](r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -7,7 +7,26 @@ import (
|
|||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (auth *Authenticator[T]) RefreshAuthTokens(tx DBTransaction, w http.ResponseWriter, r *http.Request) error {
|
// RefreshAuthTokens manually refreshes the user's authentication tokens.
|
||||||
|
// This revokes the old tokens and issues new ones.
|
||||||
|
// Requires a database transaction for token operations.
|
||||||
|
//
|
||||||
|
// Note: Token refresh is normally handled automatically by the Authenticate middleware.
|
||||||
|
// Use this method only when you need explicit control over token refresh.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// func refreshHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// tx, _ := db.BeginTx(r.Context(), nil)
|
||||||
|
// defer tx.Rollback()
|
||||||
|
// if err := auth.RefreshAuthTokens(tx, w, r); err != nil {
|
||||||
|
// http.Error(w, "Refresh failed", http.StatusUnauthorized)
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
// tx.Commit()
|
||||||
|
// w.WriteHeader(http.StatusOK)
|
||||||
|
// }
|
||||||
|
func (auth *Authenticator[T, TX]) RefreshAuthTokens(tx TX, w http.ResponseWriter, r *http.Request) error {
|
||||||
aT, rT, err := auth.getTokens(tx, r)
|
aT, rT, err := auth.getTokens(tx, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "getTokens")
|
return errors.Wrap(err, "getTokens")
|
||||||
@@ -21,7 +40,7 @@ func (auth *Authenticator[T]) RefreshAuthTokens(tx DBTransaction, w http.Respons
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "jwt.SetTokenCookies")
|
return errors.Wrap(err, "jwt.SetTokenCookies")
|
||||||
}
|
}
|
||||||
err = revokeTokenPair(tx, aT, rT)
|
err = revokeTokenPair(jwt.DBTransaction(tx), aT, rT)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "revokeTokenPair")
|
return errors.Wrap(err, "revokeTokenPair")
|
||||||
}
|
}
|
||||||
@@ -30,17 +49,17 @@ func (auth *Authenticator[T]) RefreshAuthTokens(tx DBTransaction, w http.Respons
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get the tokens from the request
|
// Get the tokens from the request
|
||||||
func (auth *Authenticator[T]) getTokens(
|
func (auth *Authenticator[T, TX]) getTokens(
|
||||||
tx DBTransaction,
|
tx TX,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
) (*jwt.AccessToken, *jwt.RefreshToken, error) {
|
) (*jwt.AccessToken, *jwt.RefreshToken, error) {
|
||||||
// get the existing tokens from the cookies
|
// get the existing tokens from the cookies
|
||||||
atStr, rtStr := jwt.GetTokenCookies(r)
|
atStr, rtStr := jwt.GetTokenCookies(r)
|
||||||
aT, err := auth.tokenGenerator.ValidateAccess(tx, atStr)
|
aT, err := auth.tokenGenerator.ValidateAccess(jwt.DBTransaction(tx), atStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, errors.Wrap(err, "tokenGenerator.ValidateAccess")
|
return nil, nil, errors.Wrap(err, "tokenGenerator.ValidateAccess")
|
||||||
}
|
}
|
||||||
rT, err := auth.tokenGenerator.ValidateRefresh(tx, rtStr)
|
rT, err := auth.tokenGenerator.ValidateRefresh(jwt.DBTransaction(tx), rtStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, errors.Wrap(err, "tokenGenerator.ValidateRefresh")
|
return nil, nil, errors.Wrap(err, "tokenGenerator.ValidateRefresh")
|
||||||
}
|
}
|
||||||
@@ -49,7 +68,7 @@ func (auth *Authenticator[T]) getTokens(
|
|||||||
|
|
||||||
// Revoke the given token pair
|
// Revoke the given token pair
|
||||||
func revokeTokenPair(
|
func revokeTokenPair(
|
||||||
tx DBTransaction,
|
tx jwt.DBTransaction,
|
||||||
aT *jwt.AccessToken,
|
aT *jwt.AccessToken,
|
||||||
rT *jwt.RefreshToken,
|
rT *jwt.RefreshToken,
|
||||||
) error {
|
) error {
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Attempt to use a valid refresh token to generate a new token pair
|
// Attempt to use a valid refresh token to generate a new token pair
|
||||||
func (auth *Authenticator[T]) refreshAuthTokens(
|
func (auth *Authenticator[T, TX]) refreshAuthTokens(
|
||||||
tx DBTransaction,
|
tx TX,
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
rT *jwt.RefreshToken,
|
rT *jwt.RefreshToken,
|
||||||
) (T, error) {
|
) (T, error) {
|
||||||
model, err := auth.load(tx, rT.SUB)
|
model, err := auth.load(r.Context(), tx, rT.SUB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return getNil[T](), errors.Wrap(err, "auth.load")
|
return getNil[T](), errors.Wrap(err, "auth.load")
|
||||||
}
|
}
|
||||||
@@ -25,12 +25,12 @@ func (auth *Authenticator[T]) refreshAuthTokens(
|
|||||||
}[rT.TTL]
|
}[rT.TTL]
|
||||||
|
|
||||||
// Set fresh to true because new tokens coming from refresh request
|
// Set fresh to true because new tokens coming from refresh request
|
||||||
err = jwt.SetTokenCookies(w, r, auth.tokenGenerator, model.ID(), false, rememberMe, auth.SSL)
|
err = jwt.SetTokenCookies(w, r, auth.tokenGenerator, model.GetID(), false, rememberMe, auth.SSL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return getNil[T](), errors.Wrap(err, "jwt.SetTokenCookies")
|
return getNil[T](), errors.Wrap(err, "jwt.SetTokenCookies")
|
||||||
}
|
}
|
||||||
// New tokens sent, revoke the old tokens
|
// New tokens sent, revoke the old tokens
|
||||||
err = rT.Revoke(tx)
|
err = rT.Revoke(jwt.DBTransaction(tx))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return getNil[T](), errors.Wrap(err, "rT.Revoke")
|
return getNil[T](), errors.Wrap(err, "rT.Revoke")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user