Compare commits

...

1 Commits

Author SHA1 Message Date
b13b783d7e created hwsauth module 2026-01-04 01:01:17 +11:00
12 changed files with 507 additions and 0 deletions

54
hwsauth/authenticate.go Normal file
View File

@@ -0,0 +1,54 @@
package hwsauth
import (
"database/sql"
"net/http"
"time"
"git.haelnorr.com/h/golib/jwt"
"github.com/pkg/errors"
)
// Check the cookies for token strings and attempt to authenticate them
func (auth *Authenticator[T]) getAuthenticatedUser(
tx *sql.Tx,
w http.ResponseWriter,
r *http.Request,
) (*authenticatedModel[T], error) {
// Get token strings from cookies
atStr, rtStr := jwt.GetTokenCookies(r)
if atStr == "" && rtStr == "" {
return nil, errors.New("No token strings provided")
}
// Attempt to parse the access token
aT, err := auth.tokenGenerator.ValidateAccess(tx, atStr)
if err != nil {
// Access token invalid, attempt to parse refresh token
rT, err := auth.tokenGenerator.ValidateRefresh(tx, rtStr)
if err != nil {
return nil, errors.Wrap(err, "auth.tokenGenerator.ValidateRefresh")
}
// Refresh token valid, attempt to get a new token pair
model, err := auth.refreshAuthTokens(tx, w, r, rT)
if err != nil {
return nil, errors.Wrap(err, "auth.refreshAuthTokens")
}
// New token pair sent, return the authorized user
authUser := authenticatedModel[T]{
model: model,
fresh: time.Now().Unix(),
}
return &authUser, nil
}
// Access token valid
model, err := auth.load(tx, aT.SUB)
if err != nil {
return nil, errors.Wrap(err, "auth.load")
}
authUser := authenticatedModel[T]{
model: model,
fresh: aT.Fresh,
}
return &authUser, nil
}

93
hwsauth/authenticator.go Normal file
View File

@@ -0,0 +1,93 @@
package hwsauth
import (
"database/sql"
"projectreshoot/pkg/hws"
"git.haelnorr.com/h/golib/jwt"
"github.com/pkg/errors"
"github.com/rs/zerolog"
)
type Authenticator[T Model] struct {
tokenGenerator *jwt.TokenGenerator
load LoadFunc[T]
conn *sql.DB
ignoredPaths []string
logger *zerolog.Logger
server *hws.Server
errorPage hws.ErrorPage
SSL bool // Use SSL for JWT tokens. Default true
TrustedHost string // TrustedHost to use for SSL verification
SecretKey string // Secret key to use for JWT tokens
AccessTokenExpiry int64 // Expiry time for Access tokens in minutes. Default 5
RefreshTokenExpiry int64 // Expiry time for Refresh tokens in minutes. Default 1440 (1 day)
TokenFreshTime int64 // Expiry time of token freshness. Default 5 minutes
LandingPage string // Path of the desired landing page for logged in users
}
// NewAuthenticator creates and returns a new Authenticator using the provided configuration.
// All expiry times should be provided in minutes.
// trustedHost and secretKey strings must be provided.
func NewAuthenticator[T Model](
load LoadFunc[T],
server *hws.Server,
conn *sql.DB,
logger *zerolog.Logger,
errorPage hws.ErrorPage,
) (*Authenticator[T], error) {
if load == nil {
return nil, errors.New("No function to load model supplied")
}
if server == nil {
return nil, errors.New("No hws.Server provided")
}
if conn == nil {
return nil, errors.New("No database connection supplied")
}
if logger == nil {
return nil, errors.New("No logger provided")
}
if errorPage == nil {
return nil, errors.New("No ErrorPage provided")
}
auth := Authenticator[T]{
load: load,
server: server,
conn: conn,
logger: logger,
errorPage: errorPage,
AccessTokenExpiry: 5,
RefreshTokenExpiry: 1440,
TokenFreshTime: 5,
SSL: true,
}
return &auth, nil
}
// Initialise finishes the setup and prepares the Authenticator for use.
// Any custom configuration must be set before Initialise is called
func (auth *Authenticator[T]) Initialise() error {
if auth.TrustedHost == "" {
return errors.New("Trusted host must be provided")
}
if auth.SecretKey == "" {
return errors.New("Secret key cannot be blank")
}
if auth.LandingPage == "" {
return errors.New("No landing page specified")
}
tokenGen, err := jwt.CreateGenerator(
auth.AccessTokenExpiry,
auth.RefreshTokenExpiry,
auth.TokenFreshTime,
auth.TrustedHost,
auth.SecretKey,
auth.conn,
)
if err != nil {
return errors.Wrap(err, "jwt.CreateGenerator")
}
auth.tokenGenerator = tokenGen
return nil
}

18
hwsauth/go.mod Normal file
View File

@@ -0,0 +1,18 @@
module git.haelnorr.com/h/golib/hwsauth
go 1.25.5
require (
git.haelnorr.com/h/golib/cookies v0.9.0
git.haelnorr.com/h/golib/jwt v0.9.2
github.com/pkg/errors v0.9.1
github.com/rs/zerolog v1.34.0
)
require (
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
golang.org/x/sys v0.12.0 // indirect
)

34
hwsauth/go.sum Normal file
View File

@@ -0,0 +1,34 @@
git.haelnorr.com/h/golib/cookies v0.9.0 h1:Vf+eX1prHkKuGrQon1BHY87yaPc1H+HJFRXDOV/AuWs=
git.haelnorr.com/h/golib/cookies v0.9.0/go.mod h1:y1385YExI9gLwckCVDCYVcsFXr6N7T3brJjnJD2QIuo=
git.haelnorr.com/h/golib/jwt v0.9.2 h1:l1Ow7DPGACAU54CnMP/NlZjdc4nRD1wr3xZ8a7taRvU=
git.haelnorr.com/h/golib/jwt v0.9.2/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/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
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/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-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/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
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.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=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

22
hwsauth/ignorepaths.go Normal file
View File

@@ -0,0 +1,22 @@
package hwsauth
import (
"fmt"
"net/url"
)
func (auth *Authenticator[T]) IgnorePaths(paths ...string) error {
for _, path := range paths {
u, err := url.Parse(path)
valid := err == nil &&
u.Scheme == "" &&
u.Host == "" &&
u.RawQuery == "" &&
u.Fragment == ""
if !valid {
return fmt.Errorf("Invalid path: '%s'", path)
}
}
auth.ignoredPaths = paths
return nil
}

22
hwsauth/login.go Normal file
View File

@@ -0,0 +1,22 @@
package hwsauth
import (
"net/http"
"git.haelnorr.com/h/golib/jwt"
"github.com/pkg/errors"
)
func (auth *Authenticator[T]) Login(
w http.ResponseWriter,
r *http.Request,
model T,
rememberMe bool,
) error {
err := jwt.SetTokenCookies(w, r, auth.tokenGenerator, model.ID(), true, rememberMe, auth.SSL)
if err != nil {
return errors.Wrap(err, "jwt.SetTokenCookies")
}
return nil
}

27
hwsauth/logout.go Normal file
View File

@@ -0,0 +1,27 @@
package hwsauth
import (
"database/sql"
"net/http"
"git.haelnorr.com/h/golib/cookies"
"github.com/pkg/errors"
)
func (auth *Authenticator[T]) Logout(tx *sql.Tx, w http.ResponseWriter, r *http.Request) error {
aT, rT, err := auth.getTokens(tx, r)
if err != nil {
return errors.Wrap(err, "auth.getTokens")
}
err = aT.Revoke(tx)
if err != nil {
return errors.Wrap(err, "aT.Revoke")
}
err = rT.Revoke(tx)
if err != nil {
return errors.Wrap(err, "rT.Revoke")
}
cookies.DeleteCookie(w, "access", "/")
cookies.DeleteCookie(w, "refresh", "/")
return nil
}

42
hwsauth/middleware.go Normal file
View File

@@ -0,0 +1,42 @@
package hwsauth
import (
"context"
"net/http"
"projectreshoot/pkg/hws"
"slices"
"time"
)
func (auth *Authenticator[T]) Authenticate() hws.Middleware {
return auth.server.NewMiddleware(auth.authenticate())
}
func (auth *Authenticator[T]) authenticate() hws.MiddlewareFunc {
return func(w http.ResponseWriter, r *http.Request) (*http.Request, *hws.HWSError) {
if slices.Contains(auth.ignoredPaths, r.URL.Path) {
return r, nil
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
// Start the transaction
tx, err := auth.conn.BeginTx(ctx, nil)
if err != nil {
return nil, hws.NewError(http.StatusServiceUnavailable, "Unable to start transaction", err)
}
model, err := auth.getAuthenticatedUser(tx, w, r)
if err != nil {
tx.Rollback()
auth.logger.Debug().
Str("remote_addr", r.RemoteAddr).
Err(err).
Msg("Failed to authenticate user")
return r, nil
}
tx.Commit()
authContext := setAuthenticatedModel(r.Context(), model)
newReq := r.WithContext(authContext)
return newReq, nil
}
}

46
hwsauth/model.go Normal file
View File

@@ -0,0 +1,46 @@
package hwsauth
import (
"context"
"database/sql"
)
type authenticatedModel[T Model] struct {
model T
fresh int64
}
func getNil[T Model]() T {
var result T
return result
}
type Model interface {
ID() int
}
type ContextLoader[T Model] func(ctx context.Context) T
type LoadFunc[T Model] func(tx *sql.Tx, id int) (T, error)
// Return a new context with the user added in
func setAuthenticatedModel[T Model](ctx context.Context, m *authenticatedModel[T]) context.Context {
return context.WithValue(ctx, "hwsauth context key authenticated-model", m)
}
// Retrieve a user from the given context. Returns nil if not set
func getAuthorizedModel[T Model](ctx context.Context) *authenticatedModel[T] {
model, ok := ctx.Value("hwsauth context key authenticated-model").(*authenticatedModel[T])
if !ok {
return nil
}
return model
}
func (auth *Authenticator[T]) CurrentModel(ctx context.Context) T {
model := getAuthorizedModel[T](ctx)
if model == nil {
return getNil[T]()
}
return model.model
}

43
hwsauth/protectpage.go Normal file
View File

@@ -0,0 +1,43 @@
package hwsauth
import (
"net/http"
"time"
)
// Checks if the model is set in the context and shows 401 page if not logged in
func (auth *Authenticator[T]) LoginReq(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
model := getAuthorizedModel[T](r.Context())
if model == nil {
auth.errorPage(http.StatusUnauthorized, w, r)
return
}
next.ServeHTTP(w, r)
})
}
// Checks if the model is set in the context and redirects them to the landing page if
// they are logged in
func (auth *Authenticator[T]) LogoutReq(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
model := getAuthorizedModel[T](r.Context())
if model != nil {
http.Redirect(w, r, auth.LandingPage, http.StatusFound)
return
}
next.ServeHTTP(w, r)
})
}
func (auth *Authenticator[T]) FreshReq(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
model := getAuthorizedModel[T](r.Context())
isFresh := time.Now().Before(time.Unix(model.fresh, 0))
if !isFresh {
w.WriteHeader(444)
return
}
next.ServeHTTP(w, r)
})
}

66
hwsauth/reauthenticate.go Normal file
View File

@@ -0,0 +1,66 @@
package hwsauth
import (
"database/sql"
"net/http"
"git.haelnorr.com/h/golib/jwt"
"github.com/pkg/errors"
)
func (auth *Authenticator[T]) RefreshAuthTokens(tx *sql.Tx, w http.ResponseWriter, r *http.Request) error {
aT, rT, err := auth.getTokens(tx, r)
if err != nil {
return errors.Wrap(err, "getTokens")
}
rememberMe := map[string]bool{
"session": false,
"exp": true,
}[aT.TTL]
// issue new tokens for the user
err = jwt.SetTokenCookies(w, r, auth.tokenGenerator, rT.SUB, true, rememberMe, auth.SSL)
if err != nil {
return errors.Wrap(err, "jwt.SetTokenCookies")
}
err = revokeTokenPair(tx, aT, rT)
if err != nil {
return errors.Wrap(err, "revokeTokenPair")
}
return nil
}
// Get the tokens from the request
func (auth *Authenticator[T]) getTokens(
tx *sql.Tx,
r *http.Request,
) (*jwt.AccessToken, *jwt.RefreshToken, error) {
// get the existing tokens from the cookies
atStr, rtStr := jwt.GetTokenCookies(r)
aT, err := auth.tokenGenerator.ValidateAccess(tx, atStr)
if err != nil {
return nil, nil, errors.Wrap(err, "tokenGenerator.ValidateAccess")
}
rT, err := auth.tokenGenerator.ValidateRefresh(tx, rtStr)
if err != nil {
return nil, nil, errors.Wrap(err, "tokenGenerator.ValidateRefresh")
}
return aT, rT, nil
}
// Revoke the given token pair
func revokeTokenPair(
tx *sql.Tx,
aT *jwt.AccessToken,
rT *jwt.RefreshToken,
) error {
err := aT.Revoke(tx)
if err != nil {
return errors.Wrap(err, "aT.Revoke")
}
err = rT.Revoke(tx)
if err != nil {
return errors.Wrap(err, "rT.Revoke")
}
return nil
}

40
hwsauth/refreshtokens.go Normal file
View File

@@ -0,0 +1,40 @@
package hwsauth
import (
"database/sql"
"net/http"
"git.haelnorr.com/h/golib/jwt"
"github.com/pkg/errors"
)
// Attempt to use a valid refresh token to generate a new token pair
func (auth *Authenticator[T]) refreshAuthTokens(
tx *sql.Tx,
w http.ResponseWriter,
r *http.Request,
rT *jwt.RefreshToken,
) (T, error) {
model, err := auth.load(tx, rT.SUB)
if err != nil {
return getNil[T](), errors.Wrap(err, "auth.load")
}
rememberMe := map[string]bool{
"session": false,
"exp": true,
}[rT.TTL]
// Set fresh to true because new tokens coming from refresh request
err = jwt.SetTokenCookies(w, r, auth.tokenGenerator, model.ID(), false, rememberMe, auth.SSL)
if err != nil {
return getNil[T](), errors.Wrap(err, "jwt.SetTokenCookies")
}
// New tokens sent, revoke the old tokens
err = rT.Revoke(tx)
if err != nil {
return getNil[T](), errors.Wrap(err, "rT.Revoke")
}
// Return the authorized user
return model, nil
}