Compare commits
2 Commits
jwt/v0.10.
...
hws/v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||||
|
|
||||||
|
[](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
|
||||||
@@ -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.
|
||||||
129
hwsauth/README.md
Normal file
129
hwsauth/README.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# 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 HWS server
|
||||||
|
server := hws.NewServer(":8080", logger)
|
||||||
|
|
||||||
|
// Create authenticator
|
||||||
|
auth, _ := hwsauth.NewAuthenticator[User, *sql.Tx](
|
||||||
|
cfg,
|
||||||
|
loadUser,
|
||||||
|
server,
|
||||||
|
beginTx,
|
||||||
|
logger,
|
||||||
|
errorPageFunc,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add authentication middleware
|
||||||
|
server.AddMiddleware(auth.Authenticate())
|
||||||
|
|
||||||
|
// Optionally ignore public paths
|
||||||
|
auth.IgnorePaths("/", "/login", "/register", "/static")
|
||||||
|
|
||||||
|
// Protect routes
|
||||||
|
protectedHandler := auth.LoginReq(http.HandlerFunc(dashboardHandler))
|
||||||
|
server.AddRoute("GET", "/dashboard", protectedHandler)
|
||||||
|
|
||||||
|
server.Start()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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
|
||||||
@@ -2,17 +2,17 @@ module git.haelnorr.com/h/golib/hwsauth
|
|||||||
|
|
||||||
go 1.25.5
|
go 1.25.5
|
||||||
|
|
||||||
|
replace git.haelnorr.com/h/golib/hws => ../hws
|
||||||
|
|
||||||
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.1.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.0 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ git.haelnorr.com/h/golib/env v0.9.1 h1:2Vsj+mJKnO5f1Md1GO5v9ggLN5zWa0baCewcSHTjo
|
|||||||
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.0 h1:ib8n2MdmiRK2TF067p220kXmhDe9aAnlcsgpuv+QpvE=
|
||||||
git.haelnorr.com/h/golib/hlog v0.9.0/go.mod h1:oOlzb8UVHUYP1k7dN5PSJXVskAB2z8EYgRN85jAi0Zk=
|
git.haelnorr.com/h/golib/hlog v0.9.0/go.mod h1:oOlzb8UVHUYP1k7dN5PSJXVskAB2z8EYgRN85jAi0Zk=
|
||||||
git.haelnorr.com/h/golib/jwt v0.9.2 h1:l1Ow7DPGACAU54CnMP/NlZjdc4nRD1wr3xZ8a7taRvU=
|
git.haelnorr.com/h/golib/jwt v0.10.0 h1:8cI8mSnb8X+EmJtrBO/5UZwuBMtib0IE9dv85gkm94E=
|
||||||
git.haelnorr.com/h/golib/jwt v0.9.2/go.mod h1:fbuPrfucT9lL0faV5+Q5Gk9WFJxPlwzRPpbMQKYZok4=
|
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=
|
||||||
|
|||||||
@@ -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