Skip to content

Commit 6996132

Browse files
committed
feat(auth): implement basic http auth with htpasswd file
- added htpasswd-based basic authentication support - supports bcrypt, MD5, SHA, SHA-256, SHA-512, crypt, and plain hash types - config via AUTH__HTPASSWD__FILE or AUTH__HTPASSWD__CONTENTS env vars - added conformance testing with auth matrix (none, htpasswd) - includes comprehensive auth spec tests - prevents user enumeration attacks Assisted-by: Tern (OpenClaw on GLM 4.7)
1 parent e770e5d commit 6996132

File tree

10 files changed

+582
-10
lines changed

10 files changed

+582
-10
lines changed

.github/workflows/conformance.yml

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ on:
99
jobs:
1010
conformance:
1111
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
auth: [none, htpasswd]
15+
fail-fast: false
1216

1317
steps:
1418
- name: Checkout
@@ -31,6 +35,21 @@ jobs:
3135
cd distribution-spec/conformance
3236
go test -c
3337
38+
- name: Setup auth
39+
run: |
40+
case "${{ matrix.auth }}" in
41+
none)
42+
echo "running with no authentication"
43+
;;
44+
htpasswd)
45+
htpasswd -Bbc .htpasswd testuser testpass
46+
echo "AUTH__MODE=htpasswd" >> $GITHUB_ENV
47+
echo "AUTH__HTPASSWD__FILE=.htpasswd" >> $GITHUB_ENV
48+
echo "OCI_USERNAME=testuser" >> $GITHUB_ENV
49+
echo "OCI_PASSWORD=testpass" >> $GITHUB_ENV
50+
;;
51+
esac
52+
3453
- name: Start sorcerer server
3554
run: |
3655
./sorcerer &
@@ -63,7 +82,7 @@ jobs:
6382
if: always()
6483
uses: actions/upload-artifact@v4
6584
with:
66-
name: conformance-results
85+
name: conformance-results-${{ matrix.auth }}
6786
path: |
6887
distribution-spec/conformance/report.html
6988
distribution-spec/conformance/junit.xml

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ container images privately.
1414
- Simple configuration
1515
- Minimal dependencies
1616
- Lightweight design
17+
- HTPASSWD authentication support
1718

1819

1920
## Usage
@@ -51,7 +52,9 @@ Sorcerer can be configured using the following environment variables:
5152
| -------------------- | ------- | ------------------------------------------------------------------------------- |
5253
| `PORT` | `3000` | Port to run the server on. |
5354
| `STORE__PATH` | `data` | Path to store registry data. |
54-
| `AUTH__MODE` | `none` | Authentication mode. Can only be set to `none` for now. |
55+
| `AUTH__MODE` | `none` | Authentication mode. Can be `none` or `htpasswd`. |
56+
| `AUTH__HTPASSWD__FILE` | - | Path to htpasswd file (required when AUTH__MODE=htpasswd). |
57+
| `AUTH__HTPASSWD__CONTENTS` | - | Inline htpasswd contents (alternative to file). One per line in `user:hash` format. |
5558
| `LOG__LEVEL` | `info` | Log level. Can be set to `debug`, `info`, `warn`, `error`, `fatal`, or `panic`. |
5659

5760

cmd/sorcerer/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ func main() {
3030
log.Fatal().Errs("errors", errors).Msg("config validations failed")
3131
}
3232

33-
auth, err := auth.New(&config.Auth)
33+
auth, err := auth.New(&config.Auth, &log.Logger)
3434
if err != nil {
3535
log.Fatal().Err(err).Msg("failed to initialize auth")
3636
}

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,12 @@ require (
99
github.com/knadh/koanf/v2 v2.2.0
1010
github.com/opencontainers/distribution-spec/specs-go v0.0.0-20250220192232-583e014d1541
1111
github.com/rs/zerolog v1.34.0
12+
github.com/tg123/go-htpasswd v1.2.4
13+
golang.org/x/crypto v0.37.0
1214
)
1315

1416
require (
17+
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 // indirect
1518
github.com/fatih/structs v1.1.0 // indirect
1619
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
1720
github.com/knadh/koanf/maps v0.1.2 // indirect

go.sum

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI=
2+
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec=
13
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
24
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
35
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -37,6 +39,12 @@ github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
3739
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
3840
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
3941
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
42+
github.com/tg123/go-htpasswd v1.2.4 h1:HgH8KKCjdmo7jjXWN9k1nefPBd7Be3tFCTjc2jPraPU=
43+
github.com/tg123/go-htpasswd v1.2.4/go.mod h1:EKThQok9xHkun6NBMynNv6Jmu24A33XdZzzl4Q7H1+0=
44+
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
45+
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
46+
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
47+
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
4048
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
4149
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
4250
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

internal/auth/auth.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,24 @@ import (
55
"fmt"
66
"net/http"
77

8+
"github.com/dvjn/sorcerer/internal/auth/htpasswd"
89
"github.com/dvjn/sorcerer/internal/auth/no_auth"
910
"github.com/dvjn/sorcerer/internal/config"
1011
"github.com/go-chi/chi/v5"
12+
"github.com/rs/zerolog"
1113
)
1214

1315
type Auth interface {
1416
Router() *chi.Mux
1517
DistributionMiddleware() func(http.Handler) http.Handler
1618
}
1719

18-
func New(c *config.AuthConfig) (Auth, error) {
20+
func New(c *config.AuthConfig, logger *zerolog.Logger) (Auth, error) {
1921
switch c.Mode {
2022
case config.AuthModeNone:
2123
return no_auth.New(&c.NoAuth), nil
24+
case config.AuthModeHtpasswd:
25+
return htpasswd.NewHtpasswdAuth(&c.Htpasswd, logger)
2226
default:
2327
return nil, fmt.Errorf("unknown auth mode: %s", c.Mode)
2428
}

internal/auth/htpasswd/htpasswd.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package htpasswd
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/dvjn/sorcerer/internal/config"
8+
"github.com/go-chi/chi/v5"
9+
"github.com/rs/zerolog"
10+
htpasswdlib "github.com/tg123/go-htpasswd"
11+
)
12+
13+
type HtpasswdAuth struct {
14+
config *config.HtpasswdConfig
15+
file *htpasswdlib.File
16+
logger *zerolog.Logger
17+
}
18+
19+
func NewHtpasswdAuth(cfg *config.HtpasswdConfig, logger *zerolog.Logger) (*HtpasswdAuth, error) {
20+
if logger == nil {
21+
return nil, fmt.Errorf("logger is required")
22+
}
23+
24+
auth := &HtpasswdAuth{
25+
config: cfg,
26+
logger: logger,
27+
}
28+
29+
if err := auth.loadHtpasswdFile(); err != nil {
30+
return nil, fmt.Errorf("failed to load htpasswd data: %w", err)
31+
}
32+
33+
// Log successful initialization
34+
auth.logger.Info().
35+
Str("auth_type", "htpasswd").
36+
Msg("htpasswd authentication initialized")
37+
38+
return auth, nil
39+
}
40+
41+
func (a *HtpasswdAuth) Router() *chi.Mux {
42+
r := chi.NewRouter()
43+
return r
44+
}
45+
46+
func (a *HtpasswdAuth) loadHtpasswdFile() error {
47+
if a.config.Contents != "" {
48+
a.logger.Info().
49+
Str("source", "inline").
50+
Msg("loading htpasswd from inline contents")
51+
r := strings.NewReader(a.config.Contents)
52+
file, err := htpasswdlib.NewFromReader(r, htpasswdlib.DefaultSystems, nil)
53+
if err != nil {
54+
return fmt.Errorf("failed to parse htpasswd content: %w", err)
55+
}
56+
a.file = file
57+
return nil
58+
}
59+
60+
if a.config.File != "" {
61+
a.logger.Info().
62+
Str("source", "file").
63+
Str("file", a.config.File).
64+
Msg("loading htpasswd from file")
65+
66+
// Load using the library
67+
file, err := htpasswdlib.New(a.config.File, htpasswdlib.DefaultSystems, nil)
68+
if err != nil {
69+
return fmt.Errorf("failed to load htpasswd file %s: %w", a.config.File, err)
70+
}
71+
72+
a.file = file
73+
return nil
74+
}
75+
76+
return fmt.Errorf("neither file nor contents provided for htpasswd auth")
77+
}
78+
79+
// Match checks if username and password are valid
80+
func (a *HtpasswdAuth) Match(username, password string) bool {
81+
if a.file == nil {
82+
return false
83+
}
84+
return a.file.Match(username, password)
85+
}

0 commit comments

Comments
 (0)