add auth lib
This commit is contained in:
62
.drone.yml
Normal file
62
.drone.yml
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: go-auth-ci
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
branch:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
event:
|
||||||
|
- push
|
||||||
|
- pull_request
|
||||||
|
|
||||||
|
environment:
|
||||||
|
GOPROXY: https://nexus.beatrice.wtf/repository/go-group/,direct
|
||||||
|
GOPRIVATE: git.beatrice.wtf/panic.haus/*
|
||||||
|
GONOSUMDB: git.beatrice.wtf/panic.haus/*
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: test
|
||||||
|
image: golang:1.26
|
||||||
|
commands:
|
||||||
|
- go mod download
|
||||||
|
- go test ./... -count=1
|
||||||
|
|
||||||
|
- name: build
|
||||||
|
image: golang:1.26
|
||||||
|
commands:
|
||||||
|
- go build ./...
|
||||||
|
|
||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: go-auth-release
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
event:
|
||||||
|
- tag
|
||||||
|
ref:
|
||||||
|
- refs/tags/v*
|
||||||
|
|
||||||
|
environment:
|
||||||
|
GOPROXY: https://nexus.beatrice.wtf/repository/go-group/,direct
|
||||||
|
GOPRIVATE: git.beatrice.wtf/panic.haus/*
|
||||||
|
GONOSUMDB: git.beatrice.wtf/panic.haus/*
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: test
|
||||||
|
image: golang:1.26
|
||||||
|
commands:
|
||||||
|
- go mod download
|
||||||
|
- go test ./... -count=1
|
||||||
|
|
||||||
|
- name: build
|
||||||
|
image: golang:1.26
|
||||||
|
commands:
|
||||||
|
- go build ./...
|
||||||
|
|
||||||
|
- name: warm-proxy
|
||||||
|
image: golang:1.26
|
||||||
|
commands:
|
||||||
|
- go list -m git.beatrice.wtf/panic.haus/go-auth@${DRONE_TAG}
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
coverage.out
|
||||||
|
bin/
|
||||||
9
README.md
Normal file
9
README.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# go-auth
|
||||||
|
|
||||||
|
Reusable backend authentication/security module.
|
||||||
|
|
||||||
|
## Packages
|
||||||
|
|
||||||
|
- `jwt`: JWT manager and claims helpers.
|
||||||
|
- `token`: secure token generation and hashing utilities.
|
||||||
|
- `credentials`: username/email/password validation helpers.
|
||||||
42
credentials/validation.go
Normal file
42
credentials/validation.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package credentials
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var emailRegex = regexp.MustCompile(`^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$`)
|
||||||
|
var usernameAllowedRegex = regexp.MustCompile(`^[A-Za-z0-9._-]{3,20}$`)
|
||||||
|
|
||||||
|
const bcryptMaxPasswordBytes = 72
|
||||||
|
|
||||||
|
// At least one letter.
|
||||||
|
var passwordLetterRegex = regexp.MustCompile(`[A-Za-z]`)
|
||||||
|
|
||||||
|
// At least one number or special symbol (non-letter).
|
||||||
|
var passwordNonLetterRegex = regexp.MustCompile(`[^A-Za-z]`)
|
||||||
|
|
||||||
|
func IsValidEmail(email string) bool {
|
||||||
|
return emailRegex.MatchString(email)
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsValidUsername(username string) bool {
|
||||||
|
if !usernameAllowedRegex.MatchString(username) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return !strings.Contains(username, "..") &&
|
||||||
|
!strings.Contains(username, "--") &&
|
||||||
|
!strings.Contains(username, "__")
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsValidPassword(password string) bool {
|
||||||
|
return len(password) >= 12 &&
|
||||||
|
IsPasswordWithinBcryptLimit(password) &&
|
||||||
|
passwordLetterRegex.MatchString(password) &&
|
||||||
|
passwordNonLetterRegex.MatchString(password)
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsPasswordWithinBcryptLimit(password string) bool {
|
||||||
|
return len([]byte(password)) <= bcryptMaxPasswordBytes
|
||||||
|
}
|
||||||
71
credentials/validation_test.go
Normal file
71
credentials/validation_test.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package credentials
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsValidEmail(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
email string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{email: "a@example.com", want: true},
|
||||||
|
{email: "user.name+tag@example.co.uk", want: true},
|
||||||
|
{email: "", want: false},
|
||||||
|
{email: "plainaddress", want: false},
|
||||||
|
{email: "user@localhost", want: false},
|
||||||
|
{email: "user@example", want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
if got := IsValidEmail(tc.email); got != tc.want {
|
||||||
|
t.Fatalf("IsValidEmail(%q) = %v, want %v", tc.email, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsValidUsername(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
username string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{username: "abc", want: true},
|
||||||
|
{username: "user_name-1", want: true},
|
||||||
|
{username: "ab", want: false},
|
||||||
|
{username: "this_username_is_way_too_long_for_rules", want: false},
|
||||||
|
{username: "user..name", want: false},
|
||||||
|
{username: "user__name", want: false},
|
||||||
|
{username: "user--name", want: false},
|
||||||
|
{username: "user name", want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
if got := IsValidUsername(tc.username); got != tc.want {
|
||||||
|
t.Fatalf("IsValidUsername(%q) = %v, want %v", tc.username, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsValidPassword(t *testing.T) {
|
||||||
|
longerThan72 := "A1" + strings.Repeat("x", 71)
|
||||||
|
exactly72 := "A1" + strings.Repeat("x", 70)
|
||||||
|
tests := []struct {
|
||||||
|
password string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{password: "LongPassword1", want: true},
|
||||||
|
{password: "abcdefghijk1", want: true},
|
||||||
|
{password: exactly72, want: true},
|
||||||
|
{password: longerThan72, want: false},
|
||||||
|
{password: "123456789012", want: false},
|
||||||
|
{password: "OnlyLettersAB", want: false},
|
||||||
|
{password: "short1A", want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
if got := IsValidPassword(tc.password); got != tc.want {
|
||||||
|
t.Fatalf("IsValidPassword(%q) = %v, want %v", tc.password, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
go.mod
Normal file
8
go.mod
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
module git.beatrice.wtf/panic.haus/go-auth
|
||||||
|
|
||||||
|
go 1.25
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
)
|
||||||
89
go.sum
Normal file
89
go.sum
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
|
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||||
|
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||||
|
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||||
|
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
|
||||||
|
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
|
||||||
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
|
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
|
||||||
|
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||||
|
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||||
|
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||||
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||||
|
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||||
|
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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||||
|
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
|
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||||
|
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||||
|
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||||
|
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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||||
|
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||||
|
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||||
|
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||||
|
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
102
jwt/jwt.go
Normal file
102
jwt/jwt.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package jwt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
TokenTypeAuth = "auth"
|
||||||
|
TokenTypeRefresh = "refresh"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
TokenType string `json:"tokenType"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
type JWTManager struct {
|
||||||
|
secret []byte
|
||||||
|
authTTL time.Duration
|
||||||
|
refreshTTL time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewJWTManager(secret string, authTTL, refreshTTL time.Duration) *JWTManager {
|
||||||
|
return &JWTManager{
|
||||||
|
secret: []byte(secret),
|
||||||
|
authTTL: authTTL,
|
||||||
|
refreshTTL: refreshTTL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *JWTManager) GenerateAuthToken(userID string, username string) (string, error) {
|
||||||
|
return m.generate(userID, username, TokenTypeAuth, m.authTTL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *JWTManager) GenerateRefreshToken(userID string, username string) (string, error) {
|
||||||
|
return m.generate(userID, username, TokenTypeRefresh, m.refreshTTL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *JWTManager) generate(userID string, username, tokenType string, ttl time.Duration) (string, error) {
|
||||||
|
now := time.Now()
|
||||||
|
claims := Claims{
|
||||||
|
UserID: userID,
|
||||||
|
Username: username,
|
||||||
|
TokenType: tokenType,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||||
|
Subject: userID,
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString(m.secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *JWTManager) ParseAuthToken(tokenStr string) (*Claims, error) {
|
||||||
|
claims, err := m.parse(tokenStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if claims.TokenType != TokenTypeAuth {
|
||||||
|
return nil, fmt.Errorf("invalid token type")
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *JWTManager) ParseRefreshToken(tokenStr string) (*Claims, error) {
|
||||||
|
claims, err := m.parse(tokenStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if claims.TokenType != TokenTypeRefresh {
|
||||||
|
return nil, fmt.Errorf("invalid token type")
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *JWTManager) parse(tokenStr string) (*Claims, error) {
|
||||||
|
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (any, error) {
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected signing method")
|
||||||
|
}
|
||||||
|
return m.secret, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := token.Claims.(*Claims)
|
||||||
|
if !ok || !token.Valid {
|
||||||
|
return nil, fmt.Errorf("invalid token")
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
96
jwt/jwt_test.go
Normal file
96
jwt/jwt_test.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package jwt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestJWTManagerGenerateAndParseAuthToken(t *testing.T) {
|
||||||
|
mgr := NewJWTManager("secret", 2*time.Minute, 10*time.Minute)
|
||||||
|
|
||||||
|
token, err := mgr.GenerateAuthToken("user-1", "alice")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate auth token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := mgr.ParseAuthToken(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse auth token: %v", err)
|
||||||
|
}
|
||||||
|
if claims.UserID != "user-1" {
|
||||||
|
t.Fatalf("expected user id user-1, got %q", claims.UserID)
|
||||||
|
}
|
||||||
|
if claims.Username != "alice" {
|
||||||
|
t.Fatalf("expected username alice, got %q", claims.Username)
|
||||||
|
}
|
||||||
|
if claims.TokenType != TokenTypeAuth {
|
||||||
|
t.Fatalf("expected token type %q, got %q", TokenTypeAuth, claims.TokenType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJWTManagerRejectsWrongTokenType(t *testing.T) {
|
||||||
|
mgr := NewJWTManager("secret", time.Minute, time.Minute)
|
||||||
|
|
||||||
|
refreshToken, err := mgr.GenerateRefreshToken("user-1", "alice")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate refresh token: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := mgr.ParseAuthToken(refreshToken); err == nil {
|
||||||
|
t.Fatal("expected error when parsing refresh token as auth token")
|
||||||
|
}
|
||||||
|
|
||||||
|
authToken, err := mgr.GenerateAuthToken("user-1", "alice")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate auth token: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := mgr.ParseRefreshToken(authToken); err == nil {
|
||||||
|
t.Fatal("expected error when parsing auth token as refresh token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJWTManagerRejectsInvalidOrTamperedToken(t *testing.T) {
|
||||||
|
mgr := NewJWTManager("secret", time.Minute, time.Minute)
|
||||||
|
|
||||||
|
if _, err := mgr.ParseAuthToken("not-a-token"); err == nil {
|
||||||
|
t.Fatal("expected parse error for malformed token")
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := mgr.GenerateAuthToken("user-1", "alice")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate auth token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
otherMgr := NewJWTManager("different-secret", time.Minute, time.Minute)
|
||||||
|
if _, err := otherMgr.ParseAuthToken(token); err == nil {
|
||||||
|
t.Fatal("expected signature validation error with different secret")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJWTManagerRejectsExpiredToken(t *testing.T) {
|
||||||
|
mgr := NewJWTManager("secret", -1*time.Second, time.Minute)
|
||||||
|
|
||||||
|
token, err := mgr.GenerateAuthToken("user-1", "alice")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate expired auth token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := mgr.ParseAuthToken(token); err == nil {
|
||||||
|
t.Fatal("expected expired token error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJWTManagerRejectsNonHMACAlgorithm(t *testing.T) {
|
||||||
|
mgr := NewJWTManager("secret", time.Minute, time.Minute)
|
||||||
|
|
||||||
|
noneToken := jwt.NewWithClaims(jwt.SigningMethodNone, Claims{UserID: "u", Username: "n", TokenType: TokenTypeAuth})
|
||||||
|
tokenStr, err := noneToken.SignedString(jwt.UnsafeAllowNoneSignatureType)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sign none token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := mgr.ParseAuthToken(tokenStr); err == nil {
|
||||||
|
t.Fatal("expected error for non-HMAC algorithm")
|
||||||
|
}
|
||||||
|
}
|
||||||
25
token/token.go
Normal file
25
token/token.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package token
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GenerateURLSafe(size int) (string, error) {
|
||||||
|
if size <= 0 {
|
||||||
|
return "", fmt.Errorf("token size must be positive")
|
||||||
|
}
|
||||||
|
raw := make([]byte, size)
|
||||||
|
if _, err := rand.Read(raw); err != nil {
|
||||||
|
return "", fmt.Errorf("generate secure token: %w", err)
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(raw), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func HashSHA256(value string) string {
|
||||||
|
sum := sha256.Sum256([]byte(value))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
38
token/token_test.go
Normal file
38
token/token_test.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package token
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateURLSafe(t *testing.T) {
|
||||||
|
t.Run("invalid size", func(t *testing.T) {
|
||||||
|
if _, err := GenerateURLSafe(0); err == nil {
|
||||||
|
t.Fatal("expected error for non-positive size")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid token decodes from raw url base64", func(t *testing.T) {
|
||||||
|
got, err := GenerateURLSafe(32)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateURLSafe: %v", err)
|
||||||
|
}
|
||||||
|
if got == "" {
|
||||||
|
t.Fatal("expected non-empty token")
|
||||||
|
}
|
||||||
|
raw, err := base64.RawURLEncoding.DecodeString(got)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("token was not raw-url-base64: %v", err)
|
||||||
|
}
|
||||||
|
if len(raw) != 32 {
|
||||||
|
t.Fatalf("expected 32 random bytes, got %d", len(raw))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHashSHA256(t *testing.T) {
|
||||||
|
const expected = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
|
||||||
|
if got := HashSHA256("hello"); got != expected {
|
||||||
|
t.Fatalf("expected %q, got %q", expected, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user