Compare commits
19 Commits
jwt/v0.10.
...
hws/v0.2.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c66e6dd66 | |||
| 614be4ed0e | |||
| da8e3c2d10 | |||
| 51045537b2 | |||
| bdae21ec0b | |||
| ddd570230b | |||
| a255ee578e | |||
| 1b1fa12a45 | |||
| 90976ca98b | |||
| 328adaadee | |||
| 5be9811afc | |||
| 52341aba56 | |||
| 7471ae881b | |||
| 2a8c39002d | |||
| 8c2ca4d79a | |||
| 3726ad738a | |||
| 423a9ee26d | |||
| 9f98bbce2d | |||
| 4c5af63ea2 |
46
RULES.md
Normal file
46
RULES.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# GOLIB Rules
|
||||||
|
|
||||||
|
1. All changes should be documented
|
||||||
|
Documentation is done in a few ways:
|
||||||
|
- docstrings
|
||||||
|
- README.md
|
||||||
|
- doc.go
|
||||||
|
- wiki
|
||||||
|
|
||||||
|
The README for each module should be laid out as follows:
|
||||||
|
- Title and description with version number
|
||||||
|
- Feature list (DO NOT USE EMOTICONS)
|
||||||
|
- Installation (go get)
|
||||||
|
- Quick Start (brief example of setting up and using)
|
||||||
|
- Documentation links to the wiki (path is `../golib/wiki/<package>.md`)
|
||||||
|
- Additional information (e.g. supported databases if package has database features)
|
||||||
|
- License
|
||||||
|
- Contributing
|
||||||
|
- Related projects (if relevant)
|
||||||
|
|
||||||
|
Docstrings and doc.go should conform to godoc standards.
|
||||||
|
Any Config structs with environment variables should have their docstrings match the format
|
||||||
|
`// ENV ENV_NAME: Description (required <optional condition>) (default: <default value>)`
|
||||||
|
where the required and default fields are only present if relevant to that variable
|
||||||
|
|
||||||
|
The wiki is located at ~/projects/golib-wiki and should be laid out as follows:
|
||||||
|
- Link to wiki page from the Home page
|
||||||
|
- Title and description with version number
|
||||||
|
- Installation
|
||||||
|
- Key Concepts and features
|
||||||
|
- Quick start
|
||||||
|
- Configuration (explicity prefer using ConfigFromEnv for packages that support it)
|
||||||
|
- Detailed sections on how to use all the features
|
||||||
|
- Integration (many of the packages in this repo are designed to work in tandem. any close integration with other packages should be mentioned here)
|
||||||
|
- Best practices
|
||||||
|
- Troubleshooting
|
||||||
|
- See also (links to other related or imported packages from this repo)
|
||||||
|
- Links (GoDoc api link, source code, issue tracker)
|
||||||
|
|
||||||
|
2. All features should have tests.
|
||||||
|
Any changes to existing features or additional features implemented should have tests created and/or updated
|
||||||
|
|
||||||
|
3. Version control
|
||||||
|
Version numbers are specified using git tags.
|
||||||
|
Do not change version numbers. When updating documentation, append the branch name to the version number.
|
||||||
|
Changes made to the golib-wiki repo should be made under the same branch name as the changes made in this repo
|
||||||
21
hlog/LICENSE
Normal file
21
hlog/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.
|
||||||
73
hlog/README.md
Normal file
73
hlog/README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# HLog - v0.10.3
|
||||||
|
|
||||||
|
A structured logging package for Go built on top of [zerolog](https://github.com/rs/zerolog). HLog provides simple configuration via environment variables, flexible output options, and automatic log file management.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Multiple output modes: console, file, or both simultaneously
|
||||||
|
- Configurable log levels: trace, debug, info, warn, error, fatal, panic
|
||||||
|
- Environment variable-based configuration with ConfigFromEnv
|
||||||
|
- Automatic log file management with append or overwrite modes
|
||||||
|
- Built on zerolog for high performance and structured logging
|
||||||
|
- Error stack trace support via pkg/errors integration
|
||||||
|
- Unix timestamp format
|
||||||
|
- Console-friendly output formatting
|
||||||
|
- Multi-writer support for simultaneous console and file output
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go get git.haelnorr.com/h/golib/hlog
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/golib/hlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Load configuration from environment variables
|
||||||
|
cfg, err := hlog.ConfigFromEnv()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new logger
|
||||||
|
logger, err := hlog.NewLogger(cfg, os.Stdout)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer logger.CloseLogFile()
|
||||||
|
|
||||||
|
// Start logging
|
||||||
|
logger.Info().Msg("Application started")
|
||||||
|
logger.Debug().Str("user", "john").Msg("User logged in")
|
||||||
|
logger.Error().Err(err).Msg("Something went wrong")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
For detailed documentation, see the [HLog Wiki](https://git.haelnorr.com/h/golib/wiki/HLog.md).
|
||||||
|
|
||||||
|
Additional API documentation is available at [GoDoc](https://pkg.go.dev/git.haelnorr.com/h/golib/hlog).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||||
|
|
||||||
|
## Related Projects
|
||||||
|
|
||||||
|
- [env](https://git.haelnorr.com/h/golib/env) - Environment variable helper used by hlog for configuration
|
||||||
|
- [zerolog](https://github.com/rs/zerolog) - The underlying logging library
|
||||||
55
hlog/config.go
Normal file
55
hlog/config.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package hlog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.haelnorr.com/h/golib/env"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds the configuration settings for the logger.
|
||||||
|
// It can be populated from environment variables using ConfigFromEnv
|
||||||
|
// or created programmatically.
|
||||||
|
type Config struct {
|
||||||
|
LogLevel Level // ENV LOG_LEVEL: Log level for the logger - trace, debug, info, warn, error, fatal, panic (default: info)
|
||||||
|
LogOutput string // ENV LOG_OUTPUT: Output destination for logs - console, file, or both (default: console)
|
||||||
|
LogDir string // ENV LOG_DIR: Directory path for log files (required when LOG_OUTPUT is "file" or "both")
|
||||||
|
LogFileName string // ENV LOG_FILE_NAME: Name of the log file (required when LOG_OUTPUT is "file" or "both")
|
||||||
|
LogAppend bool // ENV LOG_APPEND: Append to existing log file or overwrite (default: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigFromEnv loads logger configuration from environment variables.
|
||||||
|
//
|
||||||
|
// Environment variables:
|
||||||
|
// - LOG_LEVEL: Log level (trace, debug, info, warn, error, fatal, panic) - default: info
|
||||||
|
// - LOG_OUTPUT: Output destination (console, file, both) - default: console
|
||||||
|
// - LOG_DIR: Directory for log files (required when LOG_OUTPUT is "file" or "both")
|
||||||
|
//
|
||||||
|
// Returns an error if:
|
||||||
|
// - LOG_LEVEL contains an invalid value
|
||||||
|
// - LOG_OUTPUT contains an invalid value
|
||||||
|
// - LogDir or LogFileName is not set and file logging is enabled
|
||||||
|
func ConfigFromEnv() (*Config, error) {
|
||||||
|
logLevel, err := LogLevel(env.String("LOG_LEVEL", "info"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "LogLevel")
|
||||||
|
}
|
||||||
|
logOutput := env.String("LOG_OUTPUT", "console")
|
||||||
|
if logOutput != "both" && logOutput != "console" && logOutput != "file" {
|
||||||
|
return nil, errors.Errorf("Invalid LOG_OUTPUT: %s", logOutput)
|
||||||
|
}
|
||||||
|
cfg := &Config{
|
||||||
|
LogLevel: logLevel,
|
||||||
|
LogOutput: logOutput,
|
||||||
|
LogDir: env.String("LOG_DIR", ""),
|
||||||
|
LogFileName: env.String("LOG_FILE_NAME", ""),
|
||||||
|
LogAppend: env.Bool("LOG_APPEND", true),
|
||||||
|
}
|
||||||
|
if cfg.LogOutput != "console" {
|
||||||
|
if cfg.LogDir == "" {
|
||||||
|
return nil, errors.New("LOG_DIR not set but file logging enabled")
|
||||||
|
}
|
||||||
|
if cfg.LogFileName == "" {
|
||||||
|
return nil, errors.New("LOG_FILE_NAME not set but file logging enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
181
hlog/config_test.go
Normal file
181
hlog/config_test.go
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
package hlog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConfigFromEnv(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
envVars map[string]string
|
||||||
|
want *Config
|
||||||
|
wantErr bool
|
||||||
|
errMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "default values",
|
||||||
|
envVars: map[string]string{},
|
||||||
|
want: &Config{
|
||||||
|
LogLevel: zerolog.InfoLevel,
|
||||||
|
LogOutput: "console",
|
||||||
|
LogDir: "",
|
||||||
|
LogFileName: "",
|
||||||
|
LogAppend: true,
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "custom values",
|
||||||
|
envVars: map[string]string{
|
||||||
|
"LOG_LEVEL": "debug",
|
||||||
|
"LOG_OUTPUT": "both",
|
||||||
|
"LOG_DIR": "/var/log/myapp",
|
||||||
|
"LOG_FILE_NAME": "application.log",
|
||||||
|
"LOG_APPEND": "false",
|
||||||
|
},
|
||||||
|
want: &Config{
|
||||||
|
LogLevel: zerolog.DebugLevel,
|
||||||
|
LogOutput: "both",
|
||||||
|
LogDir: "/var/log/myapp",
|
||||||
|
LogFileName: "application.log",
|
||||||
|
LogAppend: false,
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "file output mode",
|
||||||
|
envVars: map[string]string{
|
||||||
|
"LOG_LEVEL": "warn",
|
||||||
|
"LOG_OUTPUT": "file",
|
||||||
|
"LOG_DIR": "/tmp/logs",
|
||||||
|
"LOG_FILE_NAME": "test.log",
|
||||||
|
"LOG_APPEND": "true",
|
||||||
|
},
|
||||||
|
want: &Config{
|
||||||
|
LogLevel: zerolog.WarnLevel,
|
||||||
|
LogOutput: "file",
|
||||||
|
LogDir: "/tmp/logs",
|
||||||
|
LogFileName: "test.log",
|
||||||
|
LogAppend: true,
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid log level",
|
||||||
|
envVars: map[string]string{
|
||||||
|
"LOG_LEVEL": "invalid",
|
||||||
|
"LOG_OUTPUT": "console",
|
||||||
|
},
|
||||||
|
want: nil,
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "LogLevel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid log output",
|
||||||
|
envVars: map[string]string{
|
||||||
|
"LOG_LEVEL": "info",
|
||||||
|
"LOG_OUTPUT": "invalid",
|
||||||
|
},
|
||||||
|
want: nil,
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "Invalid LOG_OUTPUT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trace log level with defaults",
|
||||||
|
envVars: map[string]string{
|
||||||
|
"LOG_LEVEL": "trace",
|
||||||
|
"LOG_OUTPUT": "console",
|
||||||
|
},
|
||||||
|
want: &Config{
|
||||||
|
LogLevel: zerolog.TraceLevel,
|
||||||
|
LogOutput: "console",
|
||||||
|
LogDir: "",
|
||||||
|
LogFileName: "",
|
||||||
|
LogAppend: true,
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "file output without LOG_DIR",
|
||||||
|
envVars: map[string]string{
|
||||||
|
"LOG_OUTPUT": "file",
|
||||||
|
"LOG_FILE_NAME": "test.log",
|
||||||
|
},
|
||||||
|
want: nil,
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "LOG_DIR not set",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "file output without LOG_FILE_NAME",
|
||||||
|
envVars: map[string]string{
|
||||||
|
"LOG_OUTPUT": "file",
|
||||||
|
"LOG_DIR": "/tmp",
|
||||||
|
},
|
||||||
|
want: nil,
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "LOG_FILE_NAME not set",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Clear all environment variables first
|
||||||
|
os.Unsetenv("LOG_LEVEL")
|
||||||
|
os.Unsetenv("LOG_OUTPUT")
|
||||||
|
os.Unsetenv("LOG_DIR")
|
||||||
|
os.Unsetenv("LOG_FILE_NAME")
|
||||||
|
os.Unsetenv("LOG_APPEND")
|
||||||
|
|
||||||
|
// Set test environment variables (only set if value provided)
|
||||||
|
for k, v := range tt.envVars {
|
||||||
|
os.Setenv(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup after test
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("LOG_LEVEL")
|
||||||
|
os.Unsetenv("LOG_OUTPUT")
|
||||||
|
os.Unsetenv("LOG_DIR")
|
||||||
|
os.Unsetenv("LOG_FILE_NAME")
|
||||||
|
os.Unsetenv("LOG_APPEND")
|
||||||
|
}()
|
||||||
|
|
||||||
|
got, err := ConfigFromEnv()
|
||||||
|
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("ConfigFromEnv() expected error but got nil")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tt.errMsg != "" && err.Error() == "" {
|
||||||
|
t.Errorf("ConfigFromEnv() error = %v, should contain %v", err, tt.errMsg)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ConfigFromEnv() unexpected error = %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.LogLevel != tt.want.LogLevel {
|
||||||
|
t.Errorf("ConfigFromEnv() LogLevel = %v, want %v", got.LogLevel, tt.want.LogLevel)
|
||||||
|
}
|
||||||
|
if got.LogOutput != tt.want.LogOutput {
|
||||||
|
t.Errorf("ConfigFromEnv() LogOutput = %v, want %v", got.LogOutput, tt.want.LogOutput)
|
||||||
|
}
|
||||||
|
if got.LogDir != tt.want.LogDir {
|
||||||
|
t.Errorf("ConfigFromEnv() LogDir = %v, want %v", got.LogDir, tt.want.LogDir)
|
||||||
|
}
|
||||||
|
if got.LogFileName != tt.want.LogFileName {
|
||||||
|
t.Errorf("ConfigFromEnv() LogFileName = %v, want %v", got.LogFileName, tt.want.LogFileName)
|
||||||
|
}
|
||||||
|
if got.LogAppend != tt.want.LogAppend {
|
||||||
|
t.Errorf("ConfigFromEnv() LogAppend = %v, want %v", got.LogAppend, tt.want.LogAppend)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
82
hlog/doc.go
Normal file
82
hlog/doc.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
// Package hlog provides a structured logging solution built on top of zerolog.
|
||||||
|
//
|
||||||
|
// hlog supports multiple output modes (console, file, or both), configurable
|
||||||
|
// log levels, and automatic log file management. It is designed to be simple
|
||||||
|
// to configure via environment variables while remaining flexible for
|
||||||
|
// programmatic configuration.
|
||||||
|
//
|
||||||
|
// # Basic Usage
|
||||||
|
//
|
||||||
|
// Create a logger with environment-based configuration:
|
||||||
|
//
|
||||||
|
// cfg, err := hlog.ConfigFromEnv()
|
||||||
|
// if err != nil {
|
||||||
|
// log.Fatal(err)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// logger, err := hlog.NewLogger(cfg, os.Stdout)
|
||||||
|
// if err != nil {
|
||||||
|
// log.Fatal(err)
|
||||||
|
// }
|
||||||
|
// defer logger.CloseLogFile()
|
||||||
|
//
|
||||||
|
// logger.Info().Msg("Application started")
|
||||||
|
//
|
||||||
|
// # Configuration
|
||||||
|
//
|
||||||
|
// hlog can be configured via environment variables using ConfigFromEnv:
|
||||||
|
//
|
||||||
|
// LOG_LEVEL=info # trace, debug, info, warn, error, fatal, panic (default: info)
|
||||||
|
// LOG_OUTPUT=console # console, file, or both (default: console)
|
||||||
|
// LOG_DIR=/var/log/app # Required when LOG_OUTPUT is "file" or "both"
|
||||||
|
// LOG_FILE_NAME=server.log # Required when LOG_OUTPUT is "file" or "both"
|
||||||
|
// LOG_APPEND=true # Append to existing file or overwrite (default: true)
|
||||||
|
//
|
||||||
|
// Or programmatically:
|
||||||
|
//
|
||||||
|
// cfg := &hlog.Config{
|
||||||
|
// LogLevel: hlog.InfoLevel,
|
||||||
|
// LogOutput: "both",
|
||||||
|
// LogDir: "/var/log/myapp",
|
||||||
|
// LogFileName: "server.log",
|
||||||
|
// LogAppend: true,
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// # Log Levels
|
||||||
|
//
|
||||||
|
// hlog supports the following log levels (from most to least verbose):
|
||||||
|
// - trace: Very detailed debugging information
|
||||||
|
// - debug: Detailed debugging information
|
||||||
|
// - info: General informational messages
|
||||||
|
// - warn: Warning messages for potentially harmful situations
|
||||||
|
// - error: Error messages for error events
|
||||||
|
// - fatal: Fatal messages that will exit the application
|
||||||
|
// - panic: Panic messages that will panic the application
|
||||||
|
//
|
||||||
|
// # Output Modes
|
||||||
|
//
|
||||||
|
// - console: Logs to the provided io.Writer (typically os.Stdout or os.Stderr)
|
||||||
|
// - file: Logs to a file in the configured directory
|
||||||
|
// - both: Logs to both console and file simultaneously using zerolog.MultiLevelWriter
|
||||||
|
//
|
||||||
|
// # File Management
|
||||||
|
//
|
||||||
|
// When using file output, hlog creates a file with the specified name in the
|
||||||
|
// configured directory. The file can be opened in append mode (default) to
|
||||||
|
// preserve logs across application restarts, or in overwrite mode to start
|
||||||
|
// fresh each time. Remember to call CloseLogFile() when shutting down your
|
||||||
|
// application to ensure all logs are flushed to disk.
|
||||||
|
//
|
||||||
|
// # Error Stack Traces
|
||||||
|
//
|
||||||
|
// hlog automatically configures zerolog to include stack traces for errors
|
||||||
|
// wrapped with github.com/pkg/errors. This provides detailed error context
|
||||||
|
// when using errors.Wrap or errors.WithStack.
|
||||||
|
//
|
||||||
|
// # Integration
|
||||||
|
//
|
||||||
|
// hlog integrates with:
|
||||||
|
// - git.haelnorr.com/h/golib/env: For environment variable configuration
|
||||||
|
// - github.com/rs/zerolog: The underlying logging implementation
|
||||||
|
// - github.com/pkg/errors: For error stack trace support
|
||||||
|
package hlog
|
||||||
@@ -8,6 +8,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
git.haelnorr.com/h/golib/env v0.9.1
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||||
golang.org/x/sys v0.12.0 // indirect
|
golang.org/x/sys v0.12.0 // indirect
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
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=
|
||||||
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=
|
||||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
|||||||
@@ -5,11 +5,21 @@ import (
|
|||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Level is an alias for zerolog.Level, representing the severity of a log message.
|
||||||
type Level = zerolog.Level
|
type Level = zerolog.Level
|
||||||
|
|
||||||
// Takes a log level as string and converts it to a Level interface.
|
// LogLevel converts a string to a Level value.
|
||||||
// If the string is not a valid input it will return InfoLevel
|
//
|
||||||
// Valid levels: trace, debug, info, warn, error, fatal, panic
|
// Valid level strings (case-sensitive):
|
||||||
|
// - "trace": Most verbose, for very detailed debugging
|
||||||
|
// - "debug": Detailed debugging information
|
||||||
|
// - "info": General informational messages
|
||||||
|
// - "warn": Warning messages for potentially harmful situations
|
||||||
|
// - "error": Error messages for error events
|
||||||
|
// - "fatal": Fatal messages that will exit the application
|
||||||
|
// - "panic": Panic messages that will panic the application
|
||||||
|
//
|
||||||
|
// Returns an error if the provided string is not a valid log level.
|
||||||
func LogLevel(level string) (Level, error) {
|
func LogLevel(level string) (Level, error) {
|
||||||
levels := map[string]zerolog.Level{
|
levels := map[string]zerolog.Level{
|
||||||
"trace": zerolog.TraceLevel,
|
"trace": zerolog.TraceLevel,
|
||||||
|
|||||||
155
hlog/levels_test.go
Normal file
155
hlog/levels_test.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package hlog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLogLevel(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
level string
|
||||||
|
want Level
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "trace level",
|
||||||
|
level: "trace",
|
||||||
|
want: zerolog.TraceLevel,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "debug level",
|
||||||
|
level: "debug",
|
||||||
|
want: zerolog.DebugLevel,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "info level",
|
||||||
|
level: "info",
|
||||||
|
want: zerolog.InfoLevel,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "warn level",
|
||||||
|
level: "warn",
|
||||||
|
want: zerolog.WarnLevel,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "error level",
|
||||||
|
level: "error",
|
||||||
|
want: zerolog.ErrorLevel,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fatal level",
|
||||||
|
level: "fatal",
|
||||||
|
want: zerolog.FatalLevel,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "panic level",
|
||||||
|
level: "panic",
|
||||||
|
want: zerolog.PanicLevel,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid level",
|
||||||
|
level: "invalid",
|
||||||
|
want: 0,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty string",
|
||||||
|
level: "",
|
||||||
|
want: 0,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "uppercase level (should fail - case sensitive)",
|
||||||
|
level: "INFO",
|
||||||
|
want: 0,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed case level (should fail - case sensitive)",
|
||||||
|
level: "Info",
|
||||||
|
want: 0,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "numeric string",
|
||||||
|
level: "123",
|
||||||
|
want: 0,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace",
|
||||||
|
level: " ",
|
||||||
|
want: 0,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := LogLevel(tt.level)
|
||||||
|
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("LogLevel() expected error but got nil")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("LogLevel() unexpected error = %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("LogLevel() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogLevel_AllValidLevels(t *testing.T) {
|
||||||
|
// Ensure all valid levels are tested
|
||||||
|
validLevels := map[string]Level{
|
||||||
|
"trace": zerolog.TraceLevel,
|
||||||
|
"debug": zerolog.DebugLevel,
|
||||||
|
"info": zerolog.InfoLevel,
|
||||||
|
"warn": zerolog.WarnLevel,
|
||||||
|
"error": zerolog.ErrorLevel,
|
||||||
|
"fatal": zerolog.FatalLevel,
|
||||||
|
"panic": zerolog.PanicLevel,
|
||||||
|
}
|
||||||
|
|
||||||
|
for levelStr, expectedLevel := range validLevels {
|
||||||
|
t.Run("valid_"+levelStr, func(t *testing.T) {
|
||||||
|
got, err := LogLevel(levelStr)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("LogLevel(%s) unexpected error = %v", levelStr, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if got != expectedLevel {
|
||||||
|
t.Errorf("LogLevel(%s) = %v, want %v", levelStr, got, expectedLevel)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogLevel_ErrorMessage(t *testing.T) {
|
||||||
|
_, err := LogLevel("invalid")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("LogLevel() expected error but got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedMsg := "Invalid log level specified."
|
||||||
|
if err.Error() != expectedMsg {
|
||||||
|
t.Errorf("LogLevel() error message = %v, want %v", err.Error(), expectedMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,17 +7,45 @@ import (
|
|||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Returns a pointer to a new log file with the specified path.
|
// newLogFile creates or opens the log file based on the configuration.
|
||||||
// Remember to call file.Close() when finished writing to the log file
|
// The file is created in the specified directory with the configured filename.
|
||||||
func NewLogFile(path string) (*os.File, error) {
|
// File permissions are set to 0663 (rw-rw--w-).
|
||||||
logPath := filepath.Join(path, "server.log")
|
//
|
||||||
file, err := os.OpenFile(
|
// If append is true, the file is opened in append mode and new logs are added
|
||||||
logPath,
|
// to the end. If append is false, the file is truncated on open, overwriting
|
||||||
os.O_APPEND|os.O_CREATE|os.O_WRONLY,
|
// any existing content.
|
||||||
0663,
|
//
|
||||||
)
|
// Returns an error if the file cannot be opened or created.
|
||||||
|
func newLogFile(dir, filename string, append bool) (*os.File, error) {
|
||||||
|
logPath := filepath.Join(dir, filename)
|
||||||
|
|
||||||
|
flags := os.O_CREATE | os.O_WRONLY
|
||||||
|
if append {
|
||||||
|
flags |= os.O_APPEND
|
||||||
|
} else {
|
||||||
|
flags |= os.O_TRUNC
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.OpenFile(logPath, flags, 0663)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "os.OpenFile")
|
return nil, errors.Wrap(err, "os.OpenFile")
|
||||||
}
|
}
|
||||||
return file, nil
|
return file, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CloseLogFile closes the underlying log file if one is open.
|
||||||
|
// This should be called when shutting down the application to ensure
|
||||||
|
// all buffered logs are flushed to disk.
|
||||||
|
//
|
||||||
|
// If no log file is open, this is a no-op and returns nil.
|
||||||
|
// Returns an error if the file cannot be closed.
|
||||||
|
func (l *Logger) CloseLogFile() error {
|
||||||
|
if l.logFile == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := l.logFile.Close()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
242
hlog/logfile_test.go
Normal file
242
hlog/logfile_test.go
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
package hlog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewLogFile(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
dir string
|
||||||
|
filename string
|
||||||
|
append bool
|
||||||
|
preCreate string // content to pre-create in file
|
||||||
|
write string // content to write during test
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "create new file in append mode",
|
||||||
|
dir: t.TempDir(),
|
||||||
|
filename: "test.log",
|
||||||
|
append: true,
|
||||||
|
write: "test content",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "create new file in overwrite mode",
|
||||||
|
dir: t.TempDir(),
|
||||||
|
filename: "test.log",
|
||||||
|
append: false,
|
||||||
|
write: "test content",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "append to existing file",
|
||||||
|
dir: t.TempDir(),
|
||||||
|
filename: "existing.log",
|
||||||
|
append: true,
|
||||||
|
preCreate: "existing content\n",
|
||||||
|
write: "new content\n",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "overwrite existing file",
|
||||||
|
dir: t.TempDir(),
|
||||||
|
filename: "existing.log",
|
||||||
|
append: false,
|
||||||
|
preCreate: "old content\n",
|
||||||
|
write: "new content\n",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid directory",
|
||||||
|
dir: "/nonexistent/invalid/path",
|
||||||
|
filename: "test.log",
|
||||||
|
append: true,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
logPath := filepath.Join(tt.dir, tt.filename)
|
||||||
|
|
||||||
|
// Pre-create file if needed
|
||||||
|
if tt.preCreate != "" {
|
||||||
|
err := os.WriteFile(logPath, []byte(tt.preCreate), 0663)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create pre-existing file: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create log file
|
||||||
|
file, err := newLogFile(tt.dir, tt.filename, tt.append)
|
||||||
|
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("newLogFile() expected error but got nil")
|
||||||
|
if file != nil {
|
||||||
|
file.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("newLogFile() unexpected error = %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if file == nil {
|
||||||
|
t.Errorf("newLogFile() returned nil file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Write test content
|
||||||
|
if tt.write != "" {
|
||||||
|
_, err = file.WriteString(tt.write)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Failed to write to file: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file.Sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file contents
|
||||||
|
file.Close()
|
||||||
|
content, err := os.ReadFile(logPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Failed to read file: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
contentStr := string(content)
|
||||||
|
|
||||||
|
if tt.append && tt.preCreate != "" {
|
||||||
|
// In append mode, both old and new content should exist
|
||||||
|
if !strings.Contains(contentStr, tt.preCreate) {
|
||||||
|
t.Errorf("Append mode: file missing pre-existing content. Got: %s", contentStr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(contentStr, tt.write) {
|
||||||
|
t.Errorf("Append mode: file missing new content. Got: %s", contentStr)
|
||||||
|
}
|
||||||
|
} else if !tt.append && tt.preCreate != "" {
|
||||||
|
// In overwrite mode, only new content should exist
|
||||||
|
if strings.Contains(contentStr, tt.preCreate) {
|
||||||
|
t.Errorf("Overwrite mode: file still contains old content. Got: %s", contentStr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(contentStr, tt.write) {
|
||||||
|
t.Errorf("Overwrite mode: file missing new content. Got: %s", contentStr)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// New file, should only have new content
|
||||||
|
if !strings.Contains(contentStr, tt.write) {
|
||||||
|
t.Errorf("New file: missing expected content. Got: %s", contentStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewLogFile_Permissions(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
filename := "permissions_test.log"
|
||||||
|
|
||||||
|
file, err := newLogFile(tempDir, filename, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newLogFile() error = %v", err)
|
||||||
|
}
|
||||||
|
file.Close()
|
||||||
|
|
||||||
|
logPath := filepath.Join(tempDir, filename)
|
||||||
|
_, err = os.Stat(logPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to stat file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Actual file permissions may differ from requested permissions
|
||||||
|
// due to umask settings, so we just verify the file was created
|
||||||
|
// The OS will apply umask to the requested 0663 permissions
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewLogFile_MultipleAppends(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
filename := "multiple_appends.log"
|
||||||
|
|
||||||
|
messages := []string{
|
||||||
|
"first message\n",
|
||||||
|
"second message\n",
|
||||||
|
"third message\n",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write messages sequentially
|
||||||
|
for _, msg := range messages {
|
||||||
|
file, err := newLogFile(tempDir, filename, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newLogFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = file.WriteString(msg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WriteString() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
file.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all messages are present
|
||||||
|
logPath := filepath.Join(tempDir, filename)
|
||||||
|
content, err := os.ReadFile(logPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentStr := string(content)
|
||||||
|
for _, msg := range messages {
|
||||||
|
if !strings.Contains(contentStr, msg) {
|
||||||
|
t.Errorf("File missing message: %s. Got: %s", msg, contentStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewLogFile_OverwriteClears(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
filename := "overwrite_clear.log"
|
||||||
|
|
||||||
|
// Create file with initial content
|
||||||
|
initialContent := "this should be removed\n"
|
||||||
|
file1, err := newLogFile(tempDir, filename, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newLogFile() error = %v", err)
|
||||||
|
}
|
||||||
|
file1.WriteString(initialContent)
|
||||||
|
file1.Close()
|
||||||
|
|
||||||
|
// Open in overwrite mode
|
||||||
|
newContent := "new content only\n"
|
||||||
|
file2, err := newLogFile(tempDir, filename, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newLogFile() error = %v", err)
|
||||||
|
}
|
||||||
|
file2.WriteString(newContent)
|
||||||
|
file2.Close()
|
||||||
|
|
||||||
|
// Verify only new content exists
|
||||||
|
logPath := filepath.Join(tempDir, filename)
|
||||||
|
content, err := os.ReadFile(logPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentStr := string(content)
|
||||||
|
if strings.Contains(contentStr, initialContent) {
|
||||||
|
t.Errorf("File still contains initial content after overwrite. Got: %s", contentStr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(contentStr, newContent) {
|
||||||
|
t.Errorf("File missing new content. Got: %s", contentStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,17 +9,42 @@ import (
|
|||||||
"github.com/rs/zerolog/pkgerrors"
|
"github.com/rs/zerolog/pkgerrors"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Logger = zerolog.Logger
|
// Logger wraps a zerolog.Logger and manages an optional log file.
|
||||||
|
// It embeds *zerolog.Logger, so all zerolog methods are available directly.
|
||||||
|
type Logger struct {
|
||||||
|
*zerolog.Logger
|
||||||
|
logFile *os.File
|
||||||
|
}
|
||||||
|
|
||||||
// Get a pointer to a new zerolog.Logger with the specified level and output
|
// NewLogger creates a new Logger instance based on the provided configuration.
|
||||||
// Can provide a file, writer or both. Must provide at least one of the two
|
//
|
||||||
|
// The logger output depends on cfg.LogOutput:
|
||||||
|
// - "console": Logs to the provided io.Writer w
|
||||||
|
// - "file": Logs to a file in cfg.LogDir (w can be nil)
|
||||||
|
// - "both": Logs to both the io.Writer and a file
|
||||||
|
//
|
||||||
|
// When file logging is enabled, cfg.LogDir must be set to a valid directory path.
|
||||||
|
// The log file will be named "server.log" and placed in that directory.
|
||||||
|
//
|
||||||
|
// The logger is configured with:
|
||||||
|
// - Unix timestamp format
|
||||||
|
// - Error stack trace marshaling
|
||||||
|
// - Log level from cfg.LogLevel
|
||||||
|
//
|
||||||
|
// Returns an error if:
|
||||||
|
// - cfg is nil
|
||||||
|
// - w is nil when cfg.LogOutput is not "file"
|
||||||
|
// - cfg.LogDir is empty when file logging is enabled
|
||||||
|
// - cfg.LogFileName is empty when file logging is enabled
|
||||||
|
// - The log file cannot be created
|
||||||
func NewLogger(
|
func NewLogger(
|
||||||
logLevel zerolog.Level,
|
cfg *Config,
|
||||||
w io.Writer,
|
w io.Writer,
|
||||||
logFile *os.File,
|
|
||||||
logDir string,
|
|
||||||
) (*Logger, error) {
|
) (*Logger, error) {
|
||||||
if w == nil && logFile == nil {
|
if cfg == nil {
|
||||||
|
return nil, errors.New("No config provided")
|
||||||
|
}
|
||||||
|
if w == nil && cfg.LogOutput != "file" {
|
||||||
return nil, errors.New("No Writer provided for log output.")
|
return nil, errors.New("No Writer provided for log output.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +56,21 @@ func NewLogger(
|
|||||||
consoleWriter = zerolog.ConsoleWriter{Out: w}
|
consoleWriter = zerolog.ConsoleWriter{Out: w}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var logFile *os.File
|
||||||
|
var err error
|
||||||
|
if cfg.LogOutput == "file" || cfg.LogOutput == "both" {
|
||||||
|
if cfg.LogDir == "" {
|
||||||
|
return nil, errors.New("LOG_DIR must be set when LOG_OUTPUT is 'file' or 'both'")
|
||||||
|
}
|
||||||
|
if cfg.LogFileName == "" {
|
||||||
|
return nil, errors.New("LOG_FILE_NAME must be set when LOG_OUTPUT is 'file' or 'both'")
|
||||||
|
}
|
||||||
|
logFile, err = newLogFile(cfg.LogDir, cfg.LogFileName, cfg.LogAppend)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "newLogFile")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var output io.Writer
|
var output io.Writer
|
||||||
if logFile != nil {
|
if logFile != nil {
|
||||||
if w != nil {
|
if w != nil {
|
||||||
@@ -41,11 +81,17 @@ func NewLogger(
|
|||||||
} else {
|
} else {
|
||||||
output = consoleWriter
|
output = consoleWriter
|
||||||
}
|
}
|
||||||
|
|
||||||
logger := zerolog.New(output).
|
logger := zerolog.New(output).
|
||||||
With().
|
With().
|
||||||
Timestamp().
|
Timestamp().
|
||||||
Logger().
|
Logger().
|
||||||
Level(logLevel)
|
Level(cfg.LogLevel)
|
||||||
|
|
||||||
return &logger, nil
|
hlog := &Logger{
|
||||||
|
Logger: &logger,
|
||||||
|
logFile: logFile,
|
||||||
|
}
|
||||||
|
|
||||||
|
return hlog, nil
|
||||||
}
|
}
|
||||||
|
|||||||
376
hlog/logger_test.go
Normal file
376
hlog/logger_test.go
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
package hlog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewLogger(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cfg *Config
|
||||||
|
writer io.Writer
|
||||||
|
wantErr bool
|
||||||
|
errMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "console output only",
|
||||||
|
cfg: &Config{
|
||||||
|
LogLevel: zerolog.InfoLevel,
|
||||||
|
LogOutput: "console",
|
||||||
|
LogDir: "",
|
||||||
|
LogFileName: "",
|
||||||
|
LogAppend: true,
|
||||||
|
},
|
||||||
|
writer: bytes.NewBuffer(nil),
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil config",
|
||||||
|
cfg: nil,
|
||||||
|
writer: bytes.NewBuffer(nil),
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "No config provided",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil writer for both output",
|
||||||
|
cfg: &Config{
|
||||||
|
LogLevel: zerolog.InfoLevel,
|
||||||
|
LogOutput: "both",
|
||||||
|
},
|
||||||
|
writer: nil,
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "No Writer provided",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "file output without LogDir",
|
||||||
|
cfg: &Config{
|
||||||
|
LogLevel: zerolog.InfoLevel,
|
||||||
|
LogOutput: "file",
|
||||||
|
LogDir: "",
|
||||||
|
LogFileName: "test.log",
|
||||||
|
LogAppend: true,
|
||||||
|
},
|
||||||
|
writer: nil,
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "LOG_DIR must be set",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "file output without LogFileName",
|
||||||
|
cfg: &Config{
|
||||||
|
LogLevel: zerolog.InfoLevel,
|
||||||
|
LogOutput: "file",
|
||||||
|
LogDir: "/tmp",
|
||||||
|
LogFileName: "",
|
||||||
|
LogAppend: true,
|
||||||
|
},
|
||||||
|
writer: nil,
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "LOG_FILE_NAME must be set",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "both output without LogDir",
|
||||||
|
cfg: &Config{
|
||||||
|
LogLevel: zerolog.InfoLevel,
|
||||||
|
LogOutput: "both",
|
||||||
|
LogDir: "",
|
||||||
|
LogFileName: "test.log",
|
||||||
|
LogAppend: true,
|
||||||
|
},
|
||||||
|
writer: bytes.NewBuffer(nil),
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "LOG_DIR must be set",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "both output without LogFileName",
|
||||||
|
cfg: &Config{
|
||||||
|
LogLevel: zerolog.InfoLevel,
|
||||||
|
LogOutput: "both",
|
||||||
|
LogDir: "/tmp",
|
||||||
|
LogFileName: "",
|
||||||
|
LogAppend: true,
|
||||||
|
},
|
||||||
|
writer: bytes.NewBuffer(nil),
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "LOG_FILE_NAME must be set",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
logger, err := NewLogger(tt.cfg, tt.writer)
|
||||||
|
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("NewLogger() expected error but got nil")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
|
||||||
|
t.Errorf("NewLogger() error = %v, should contain %v", err, tt.errMsg)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("NewLogger() unexpected error = %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if logger == nil {
|
||||||
|
t.Errorf("NewLogger() returned nil logger")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if logger.Logger == nil {
|
||||||
|
t.Errorf("NewLogger() returned logger with nil zerolog.Logger")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewLogger_FileOutput(t *testing.T) {
|
||||||
|
// Create temporary directory for test logs
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cfg *Config
|
||||||
|
writer io.Writer
|
||||||
|
wantErr bool
|
||||||
|
checkFile bool
|
||||||
|
logMessage string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "file output with append",
|
||||||
|
cfg: &Config{
|
||||||
|
LogLevel: zerolog.InfoLevel,
|
||||||
|
LogOutput: "file",
|
||||||
|
LogDir: tempDir,
|
||||||
|
LogFileName: "append_test.log",
|
||||||
|
LogAppend: true,
|
||||||
|
},
|
||||||
|
writer: nil,
|
||||||
|
wantErr: false,
|
||||||
|
checkFile: true,
|
||||||
|
logMessage: "test append message",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "file output with overwrite",
|
||||||
|
cfg: &Config{
|
||||||
|
LogLevel: zerolog.InfoLevel,
|
||||||
|
LogOutput: "file",
|
||||||
|
LogDir: tempDir,
|
||||||
|
LogFileName: "overwrite_test.log",
|
||||||
|
LogAppend: false,
|
||||||
|
},
|
||||||
|
writer: nil,
|
||||||
|
wantErr: false,
|
||||||
|
checkFile: true,
|
||||||
|
logMessage: "test overwrite message",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "both output modes",
|
||||||
|
cfg: &Config{
|
||||||
|
LogLevel: zerolog.DebugLevel,
|
||||||
|
LogOutput: "both",
|
||||||
|
LogDir: tempDir,
|
||||||
|
LogFileName: "both_test.log",
|
||||||
|
LogAppend: true,
|
||||||
|
},
|
||||||
|
writer: bytes.NewBuffer(nil),
|
||||||
|
wantErr: false,
|
||||||
|
checkFile: true,
|
||||||
|
logMessage: "test both message",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
logger, err := NewLogger(tt.cfg, tt.writer)
|
||||||
|
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("NewLogger() expected error but got nil")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("NewLogger() unexpected error = %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if logger == nil {
|
||||||
|
t.Errorf("NewLogger() returned nil logger")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log a test message
|
||||||
|
logger.Info().Msg(tt.logMessage)
|
||||||
|
|
||||||
|
// Close the log file to flush
|
||||||
|
err = logger.CloseLogFile()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("CloseLogFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file exists and contains message
|
||||||
|
if tt.checkFile {
|
||||||
|
logPath := filepath.Join(tt.cfg.LogDir, tt.cfg.LogFileName)
|
||||||
|
content, err := os.ReadFile(logPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Failed to read log file: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(string(content), tt.logMessage) {
|
||||||
|
t.Errorf("Log file doesn't contain expected message. Got: %s", string(content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check console output for "both" mode
|
||||||
|
if tt.cfg.LogOutput == "both" && tt.writer != nil {
|
||||||
|
if buf, ok := tt.writer.(*bytes.Buffer); ok {
|
||||||
|
consoleOutput := buf.String()
|
||||||
|
if !strings.Contains(consoleOutput, tt.logMessage) {
|
||||||
|
t.Errorf("Console output doesn't contain expected message. Got: %s", consoleOutput)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewLogger_AppendVsOverwrite(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
logFileName := "append_vs_overwrite.log"
|
||||||
|
|
||||||
|
// First logger - write initial content
|
||||||
|
cfg1 := &Config{
|
||||||
|
LogLevel: zerolog.InfoLevel,
|
||||||
|
LogOutput: "file",
|
||||||
|
LogDir: tempDir,
|
||||||
|
LogFileName: logFileName,
|
||||||
|
LogAppend: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger1, err := NewLogger(cfg1, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewLogger() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger1.Info().Msg("first message")
|
||||||
|
logger1.CloseLogFile()
|
||||||
|
|
||||||
|
// Second logger - append mode
|
||||||
|
cfg2 := &Config{
|
||||||
|
LogLevel: zerolog.InfoLevel,
|
||||||
|
LogOutput: "file",
|
||||||
|
LogDir: tempDir,
|
||||||
|
LogFileName: logFileName,
|
||||||
|
LogAppend: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger2, err := NewLogger(cfg2, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewLogger() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger2.Info().Msg("second message")
|
||||||
|
logger2.CloseLogFile()
|
||||||
|
|
||||||
|
// Check both messages exist
|
||||||
|
logPath := filepath.Join(tempDir, logFileName)
|
||||||
|
content, err := os.ReadFile(logPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read log file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentStr := string(content)
|
||||||
|
if !strings.Contains(contentStr, "first message") {
|
||||||
|
t.Errorf("Log file missing 'first message' after append")
|
||||||
|
}
|
||||||
|
if !strings.Contains(contentStr, "second message") {
|
||||||
|
t.Errorf("Log file missing 'second message' after append")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Third logger - overwrite mode
|
||||||
|
cfg3 := &Config{
|
||||||
|
LogLevel: zerolog.InfoLevel,
|
||||||
|
LogOutput: "file",
|
||||||
|
LogDir: tempDir,
|
||||||
|
LogFileName: logFileName,
|
||||||
|
LogAppend: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger3, err := NewLogger(cfg3, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewLogger() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger3.Info().Msg("third message")
|
||||||
|
logger3.CloseLogFile()
|
||||||
|
|
||||||
|
// Check only third message exists
|
||||||
|
content, err = os.ReadFile(logPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read log file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentStr = string(content)
|
||||||
|
if strings.Contains(contentStr, "first message") {
|
||||||
|
t.Errorf("Log file still contains 'first message' after overwrite")
|
||||||
|
}
|
||||||
|
if strings.Contains(contentStr, "second message") {
|
||||||
|
t.Errorf("Log file still contains 'second message' after overwrite")
|
||||||
|
}
|
||||||
|
if !strings.Contains(contentStr, "third message") {
|
||||||
|
t.Errorf("Log file missing 'third message' after overwrite")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogger_CloseLogFile(t *testing.T) {
|
||||||
|
t.Run("close with file", func(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
cfg := &Config{
|
||||||
|
LogLevel: zerolog.InfoLevel,
|
||||||
|
LogOutput: "file",
|
||||||
|
LogDir: tempDir,
|
||||||
|
LogFileName: "close_test.log",
|
||||||
|
LogAppend: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger, err := NewLogger(cfg, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewLogger() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = logger.CloseLogFile()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("CloseLogFile() error = %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("close without file", func(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
LogLevel: zerolog.InfoLevel,
|
||||||
|
LogOutput: "console",
|
||||||
|
}
|
||||||
|
|
||||||
|
logger, err := NewLogger(cfg, bytes.NewBuffer(nil))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewLogger() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = logger.CloseLogFile()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("CloseLogFile() should not error when no file is open, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
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.
|
||||||
96
hws/README.md
Normal file
96
hws/README.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# HWS (H Web Server) - v0.2.2
|
||||||
|
|
||||||
|
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 with ConfigFromEnv
|
||||||
|
- 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"
|
||||||
|
"net/http"
|
||||||
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
For detailed documentation, see the [HWS Wiki](https://git.haelnorr.com/h/golib/wiki/HWS.md).
|
||||||
|
|
||||||
|
Additional API documentation is available at [GoDoc](https://pkg.go.dev/git.haelnorr.com/h/golib/hws).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the 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)
|
||||||
|
|||||||
144
hws/doc.go
Normal file
144
hws/doc.go
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
// Package hws provides a lightweight HTTP web server framework built on top of Go's standard library.
|
||||||
|
//
|
||||||
|
// HWS (H Web Server) is an opinionated framework that leverages Go 1.22+ routing patterns
|
||||||
|
// with built-in middleware, structured error handling, and production-ready defaults. It
|
||||||
|
// integrates seamlessly with other golib packages like hlog for logging and hwsauth for
|
||||||
|
// authentication.
|
||||||
|
//
|
||||||
|
// # Basic Usage
|
||||||
|
//
|
||||||
|
// Create a server with environment-based configuration:
|
||||||
|
//
|
||||||
|
// cfg, err := hws.ConfigFromEnv()
|
||||||
|
// if err != nil {
|
||||||
|
// log.Fatal(err)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// server, err := hws.NewServer(cfg)
|
||||||
|
// if err != nil {
|
||||||
|
// log.Fatal(err)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// routes := []hws.Route{
|
||||||
|
// {
|
||||||
|
// Path: "/",
|
||||||
|
// Method: hws.MethodGET,
|
||||||
|
// Handler: http.HandlerFunc(homeHandler),
|
||||||
|
// },
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// server.AddRoutes(routes...)
|
||||||
|
// server.AddMiddleware()
|
||||||
|
//
|
||||||
|
// ctx := context.Background()
|
||||||
|
// server.Start(ctx)
|
||||||
|
//
|
||||||
|
// <-server.Ready()
|
||||||
|
//
|
||||||
|
// # Configuration
|
||||||
|
//
|
||||||
|
// HWS can be configured via environment variables using ConfigFromEnv:
|
||||||
|
//
|
||||||
|
// HWS_HOST=127.0.0.1 # Host to listen on (default: 127.0.0.1)
|
||||||
|
// HWS_PORT=3000 # Port to listen on (default: 3000)
|
||||||
|
// HWS_GZIP=false # Enable GZIP compression (default: false)
|
||||||
|
// HWS_READ_HEADER_TIMEOUT=2 # Header read timeout in seconds (default: 2)
|
||||||
|
// HWS_WRITE_TIMEOUT=10 # Write timeout in seconds (default: 10)
|
||||||
|
// HWS_IDLE_TIMEOUT=120 # Idle connection timeout in seconds (default: 120)
|
||||||
|
//
|
||||||
|
// Or programmatically:
|
||||||
|
//
|
||||||
|
// cfg := &hws.Config{
|
||||||
|
// Host: "0.0.0.0",
|
||||||
|
// Port: 8080,
|
||||||
|
// GZIP: true,
|
||||||
|
// ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
// WriteTimeout: 15 * time.Second,
|
||||||
|
// IdleTimeout: 120 * time.Second,
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// # Routing
|
||||||
|
//
|
||||||
|
// HWS uses Go 1.22+ routing patterns with method-specific handlers:
|
||||||
|
//
|
||||||
|
// routes := []hws.Route{
|
||||||
|
// {
|
||||||
|
// Path: "/users/{id}",
|
||||||
|
// Method: hws.MethodGET,
|
||||||
|
// Handler: http.HandlerFunc(getUser),
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// Path: "/users/{id}",
|
||||||
|
// Method: hws.MethodPUT,
|
||||||
|
// Handler: http.HandlerFunc(updateUser),
|
||||||
|
// },
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Path parameters can be accessed using r.PathValue():
|
||||||
|
//
|
||||||
|
// func getUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// id := r.PathValue("id")
|
||||||
|
// // ... handle request
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// # Middleware
|
||||||
|
//
|
||||||
|
// HWS supports middleware with predictable execution order. Built-in middleware includes
|
||||||
|
// request logging, timing, and GZIP compression:
|
||||||
|
//
|
||||||
|
// server.AddMiddleware()
|
||||||
|
//
|
||||||
|
// Custom middleware can be added using standard http.Handler wrapping:
|
||||||
|
//
|
||||||
|
// server.AddMiddleware(customMiddleware)
|
||||||
|
//
|
||||||
|
// # Error Handling
|
||||||
|
//
|
||||||
|
// HWS provides structured error handling with customizable error pages:
|
||||||
|
//
|
||||||
|
// errorPageFunc := func(w http.ResponseWriter, r *http.Request, status int) {
|
||||||
|
// w.WriteHeader(status)
|
||||||
|
// fmt.Fprintf(w, "Error: %d", status)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// server.AddErrorPage(errorPageFunc)
|
||||||
|
//
|
||||||
|
// # Logging
|
||||||
|
//
|
||||||
|
// HWS integrates with hlog for structured logging:
|
||||||
|
//
|
||||||
|
// logger, _ := hlog.NewLogger(loggerCfg, os.Stdout)
|
||||||
|
// server.AddLogger(logger)
|
||||||
|
//
|
||||||
|
// The server will automatically log requests, errors, and server lifecycle events.
|
||||||
|
//
|
||||||
|
// # Static Files
|
||||||
|
//
|
||||||
|
// HWS provides safe static file serving that prevents directory listing:
|
||||||
|
//
|
||||||
|
// server.AddStaticFiles("/static", "./public")
|
||||||
|
//
|
||||||
|
// # Graceful Shutdown
|
||||||
|
//
|
||||||
|
// HWS supports graceful shutdown via context cancellation:
|
||||||
|
//
|
||||||
|
// ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
// defer cancel()
|
||||||
|
//
|
||||||
|
// server.Start(ctx)
|
||||||
|
//
|
||||||
|
// // Wait for shutdown signal
|
||||||
|
// sigChan := make(chan os.Signal, 1)
|
||||||
|
// signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||||
|
// <-sigChan
|
||||||
|
//
|
||||||
|
// // Cancel context to trigger graceful shutdown
|
||||||
|
// cancel()
|
||||||
|
//
|
||||||
|
// # Integration
|
||||||
|
//
|
||||||
|
// HWS integrates with:
|
||||||
|
// - git.haelnorr.com/h/golib/hlog: For structured logging with zerolog
|
||||||
|
// - git.haelnorr.com/h/golib/hwsauth: For JWT-based authentication
|
||||||
|
// - git.haelnorr.com/h/golib/jwt: For JWT token management
|
||||||
|
package hws
|
||||||
@@ -18,7 +18,7 @@ func Test_Server_Addr(t *testing.T) {
|
|||||||
Port: 8080,
|
Port: 8080,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
addr := server.Addr()
|
addr := server.Addr()
|
||||||
assert.Equal(t, "192.168.1.1:8080", addr)
|
assert.Equal(t, "192.168.1.1:8080", addr)
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,7 @@ func Test_Server_Addr(t *testing.T) {
|
|||||||
func Test_Server_Handler(t *testing.T) {
|
func Test_Server_Handler(t *testing.T) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
server := createTestServer(t, &buf)
|
server := createTestServer(t, &buf)
|
||||||
|
|
||||||
// Add routes first
|
// Add routes first
|
||||||
handler := testHandler
|
handler := testHandler
|
||||||
err := server.AddRoutes(hws.Route{
|
err := server.AddRoutes(hws.Route{
|
||||||
@@ -35,16 +35,16 @@ func Test_Server_Handler(t *testing.T) {
|
|||||||
Handler: handler,
|
Handler: handler,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Get the handler
|
// Get the handler
|
||||||
h := server.Handler()
|
h := server.Handler()
|
||||||
require.NotNil(t, h)
|
require.NotNil(t, h)
|
||||||
|
|
||||||
// Test the handler directly with httptest
|
// Test the handler directly with httptest
|
||||||
req := httptest.NewRequest("GET", "/test", nil)
|
req := httptest.NewRequest("GET", "/test", nil)
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
h.ServeHTTP(rr, req)
|
h.ServeHTTP(rr, req)
|
||||||
|
|
||||||
assert.Equal(t, 200, rr.Code)
|
assert.Equal(t, 200, rr.Code)
|
||||||
assert.Equal(t, "hello world", rr.Body.String())
|
assert.Equal(t, "hello world", rr.Body.String())
|
||||||
}
|
}
|
||||||
@@ -52,7 +52,7 @@ func Test_Server_Handler(t *testing.T) {
|
|||||||
func Test_LoggerIgnorePaths_Integration(t *testing.T) {
|
func Test_LoggerIgnorePaths_Integration(t *testing.T) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
server := createTestServer(t, &buf)
|
server := createTestServer(t, &buf)
|
||||||
|
|
||||||
// Add routes
|
// Add routes
|
||||||
err := server.AddRoutes(hws.Route{
|
err := server.AddRoutes(hws.Route{
|
||||||
Path: "/test",
|
Path: "/test",
|
||||||
@@ -64,28 +64,28 @@ func Test_LoggerIgnorePaths_Integration(t *testing.T) {
|
|||||||
Handler: testHandler,
|
Handler: testHandler,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Set paths to ignore
|
// Set paths to ignore
|
||||||
server.LoggerIgnorePaths("/ignore", "/healthz")
|
server.LoggerIgnorePaths("/ignore", "/healthz")
|
||||||
|
|
||||||
err = server.AddMiddleware()
|
err = server.AddMiddleware()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Test that ignored path doesn't generate logs
|
// Test that ignored path doesn't generate logs
|
||||||
buf.Reset()
|
buf.Reset()
|
||||||
req := httptest.NewRequest("GET", "/ignore", nil)
|
req := httptest.NewRequest("GET", "/ignore", nil)
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
server.Handler().ServeHTTP(rr, req)
|
server.Handler().ServeHTTP(rr, req)
|
||||||
|
|
||||||
// Buffer should be empty for ignored path
|
// Buffer should be empty for ignored path
|
||||||
assert.Empty(t, buf.String())
|
assert.Empty(t, buf.String())
|
||||||
|
|
||||||
// Test that non-ignored path generates logs
|
// Test that non-ignored path generates logs
|
||||||
buf.Reset()
|
buf.Reset()
|
||||||
req = httptest.NewRequest("GET", "/test", nil)
|
req = httptest.NewRequest("GET", "/test", nil)
|
||||||
rr = httptest.NewRecorder()
|
rr = httptest.NewRecorder()
|
||||||
server.Handler().ServeHTTP(rr, req)
|
server.Handler().ServeHTTP(rr, req)
|
||||||
|
|
||||||
// Buffer should have logs for non-ignored path
|
// Buffer should have logs for non-ignored path
|
||||||
assert.NotEmpty(t, buf.String())
|
assert.NotEmpty(t, buf.String())
|
||||||
}
|
}
|
||||||
@@ -93,12 +93,12 @@ func Test_LoggerIgnorePaths_Integration(t *testing.T) {
|
|||||||
func Test_WrappedWriter(t *testing.T) {
|
func Test_WrappedWriter(t *testing.T) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
server := createTestServer(t, &buf)
|
server := createTestServer(t, &buf)
|
||||||
|
|
||||||
// Add routes with different status codes
|
// Add routes with different status codes
|
||||||
err := server.AddRoutes(
|
err := server.AddRoutes(
|
||||||
hws.Route{
|
hws.Route{
|
||||||
Path: "/ok",
|
Path: "/ok",
|
||||||
Method: hws.MethodGET,
|
Method: hws.MethodGET,
|
||||||
Handler: testHandler,
|
Handler: testHandler,
|
||||||
},
|
},
|
||||||
hws.Route{
|
hws.Route{
|
||||||
@@ -111,16 +111,16 @@ func Test_WrappedWriter(t *testing.T) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = server.AddMiddleware()
|
err = server.AddMiddleware()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Test OK status
|
// Test OK status
|
||||||
req := httptest.NewRequest("GET", "/ok", nil)
|
req := httptest.NewRequest("GET", "/ok", nil)
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
server.Handler().ServeHTTP(rr, req)
|
server.Handler().ServeHTTP(rr, req)
|
||||||
assert.Equal(t, 200, rr.Code)
|
assert.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
// Test Created status
|
// Test Created status
|
||||||
req = httptest.NewRequest("POST", "/created", nil)
|
req = httptest.NewRequest("POST", "/created", nil)
|
||||||
rr = httptest.NewRecorder()
|
rr = httptest.NewRecorder()
|
||||||
@@ -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,10 +163,10 @@ 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")
|
||||||
|
|
||||||
// Clean up
|
// Clean up
|
||||||
server.Shutdown(t.Context())
|
server.Shutdown(t.Context())
|
||||||
})
|
})
|
||||||
@@ -199,7 +199,7 @@ func Test_WaitUntilReady_ContextCancelled(t *testing.T) {
|
|||||||
|
|
||||||
// Start should return with context error since timeout is so short
|
// Start should return with context error since timeout is so short
|
||||||
err = server.Start(ctx)
|
err = server.Start(ctx)
|
||||||
|
|
||||||
// The error could be nil if server started very quickly, or context.DeadlineExceeded
|
// The error could be nil if server started very quickly, or context.DeadlineExceeded
|
||||||
// This tests the ctx.Err() path in waitUntilReady
|
// This tests the ctx.Err() path in waitUntilReady
|
||||||
if err != nil {
|
if err != 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 - v0.3.2
|
||||||
|
|
||||||
|
JWT-based authentication middleware for the 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, database/sql)
|
||||||
|
- Environment variable configuration with ConfigFromEnv
|
||||||
|
- Middleware for protecting routes
|
||||||
|
- SSL cookie security support
|
||||||
|
- Type-safe with Go generics
|
||||||
|
- Path ignoring for public routes
|
||||||
|
- Automatic re-authentication handling
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go get git.haelnorr.com/h/golib/hwsauth
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
"git.haelnorr.com/h/golib/hwsauth"
|
||||||
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 logger
|
||||||
|
logger, _ := hlog.NewLogger(loggerCfg, os.Stdout)
|
||||||
|
|
||||||
|
// Create error page function
|
||||||
|
errorPageFunc := func(w http.ResponseWriter, r *http.Request, status int) {
|
||||||
|
w.WriteHeader(status)
|
||||||
|
fmt.Fprintf(w, "Error: %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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())
|
||||||
|
|
||||||
|
// Ignore public paths
|
||||||
|
auth.IgnorePaths("/", "/login", "/register", "/static")
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
ctx := context.Background()
|
||||||
|
server.Start(ctx)
|
||||||
|
|
||||||
|
<-server.Ready()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
For detailed documentation, see the [HWSAuth Wiki](https://git.haelnorr.com/h/golib/wiki/HWSAuth.md).
|
||||||
|
|
||||||
|
Additional API documentation is available at [GoDoc](https://pkg.go.dev/git.haelnorr.com/h/golib/hwsauth).
|
||||||
|
|
||||||
|
## Supported ORMs
|
||||||
|
|
||||||
|
- database/sql (standard library)
|
||||||
|
- GORM
|
||||||
|
- Bun
|
||||||
|
- sqlx
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the LICENSE 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
|
||||||
|
- [hlog](https://git.haelnorr.com/h/golib/hlog) - Structured logging with zerolog
|
||||||
@@ -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 when using ConfigFromEnv:
|
||||||
|
//
|
||||||
|
// - HWSAUTH_SSL: Enable SSL secure cookies (default: false)
|
||||||
|
// - HWSAUTH_TRUSTED_HOST: Full server address for SSL (required if SSL is true)
|
||||||
|
// - HWSAUTH_SECRET_KEY: Secret key for signing JWT 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: Redirect destination for authenticated users (default: "/profile")
|
||||||
|
// - HWSAUTH_DATABASE_TYPE: Database type - postgres, mysql, sqlite, mariadb (default: "postgres")
|
||||||
|
// - HWSAUTH_DATABASE_VERSION: Database version string (default: "15")
|
||||||
|
// - HWSAUTH_JWT_TABLE_NAME: Custom JWT blacklist table name (default: "jwtblacklist")
|
||||||
|
//
|
||||||
|
// # 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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
# JWT Package
|
# JWT - v0.10.1
|
||||||
|
|
||||||
[](https://pkg.go.dev/git.haelnorr.com/h/golib/jwt)
|
|
||||||
|
|
||||||
JWT (JSON Web Token) generation and validation with database-backed token revocation support.
|
JWT (JSON Web Token) generation and validation with database-backed token revocation support.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- 🔐 Access and refresh token generation
|
- Access and refresh token generation
|
||||||
- ✅ Token validation with expiration checking
|
- Token validation with expiration checking
|
||||||
- 🚫 Token revocation via database blacklist
|
- Token revocation via database blacklist
|
||||||
- 🗄️ Multi-database support (PostgreSQL, MySQL, SQLite, MariaDB)
|
- Multi-database support (PostgreSQL, MySQL, SQLite, MariaDB)
|
||||||
- 🔧 Compatible with database/sql, GORM, and Bun
|
- Compatible with database/sql, GORM, and Bun ORMs
|
||||||
- 🤖 Automatic table creation and management
|
- Automatic table creation and management
|
||||||
- 🧹 Database-native automatic cleanup
|
- Database-native automatic cleanup
|
||||||
- 🔄 Token freshness tracking
|
- Token freshness tracking for sensitive operations
|
||||||
- 💾 "Remember me" functionality
|
- "Remember me" functionality with session vs persistent tokens
|
||||||
|
- Manual cleanup method for on-demand token cleanup
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -41,7 +40,7 @@ func main() {
|
|||||||
|
|
||||||
// Create a transaction getter function
|
// Create a transaction getter function
|
||||||
txGetter := func(ctx context.Context) (jwt.DBTransaction, error) {
|
txGetter := func(ctx context.Context) (jwt.DBTransaction, error) {
|
||||||
return db.Begin()
|
return db.BeginTx(ctx, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create token generator
|
// Create token generator
|
||||||
@@ -78,16 +77,9 @@ func main() {
|
|||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
Comprehensive documentation is available in the [Wiki](https://git.haelnorr.com/h/golib/wiki/JWT).
|
For detailed documentation, see the [JWT Wiki](https://git.haelnorr.com/h/golib/wiki/JWT.md).
|
||||||
|
|
||||||
### Key Topics
|
Additional API documentation is available at [GoDoc](https://pkg.go.dev/git.haelnorr.com/h/golib/jwt).
|
||||||
|
|
||||||
- [Configuration](https://git.haelnorr.com/h/golib/wiki/JWT#configuration)
|
|
||||||
- [Token Generation](https://git.haelnorr.com/h/golib/wiki/JWT#token-generation)
|
|
||||||
- [Token Validation](https://git.haelnorr.com/h/golib/wiki/JWT#token-validation)
|
|
||||||
- [Token Revocation](https://git.haelnorr.com/h/golib/wiki/JWT#token-revocation)
|
|
||||||
- [Cleanup](https://git.haelnorr.com/h/golib/wiki/JWT#cleanup)
|
|
||||||
- [Using with ORMs](https://git.haelnorr.com/h/golib/wiki/JWT#using-with-orms)
|
|
||||||
|
|
||||||
## Supported Databases
|
## Supported Databases
|
||||||
|
|
||||||
@@ -98,8 +90,13 @@ Comprehensive documentation is available in the [Wiki](https://git.haelnorr.com/
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
See LICENSE file in the repository root.
|
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Contributions are welcome! Please open an issue or submit a pull request.
|
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||||
|
|
||||||
|
## Related Projects
|
||||||
|
|
||||||
|
- [hwsauth](https://git.haelnorr.com/h/golib/hwsauth) - JWT-based authentication middleware for HWS
|
||||||
|
- [hws](https://git.haelnorr.com/h/golib/hws) - HTTP web server framework
|
||||||
|
|||||||
Reference in New Issue
Block a user