@marcel91200 / caddy Mirror · read-only / commits / 6bfeb4f8
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 11, 01:00 UTC

caddyauth: support third-party hash modules (#8063)

* caddyauth: Support third-party http.authentication.hashes modules basic_auth and hash-password only accepted the built-in bcrypt and argon2id algorithms, even though the http.authentication.hashes namespace is documented as extensible. Look up any other algorithm name among the installed hash modules instead, and list the available ones when the name is not recognized. The built-in algorithms keep their own hash-password flags; other modules are provisioned and must implement Hasher to generate hashes. Closes #6670 * caddyauth: Base64-encode non-MCF hashes from hash-password modules hash-password printed module hashes as raw bytes, but basic_auth base64-decodes any password that isn't in Modular Crypt Format, so the output couldn't be used as-is. Encode non-MCF hashes as base64 and add a round-trip test that provisions HTTPBasicAuth with the command output. * caddyauth: always base64-encode third-party hash output

hsdfat committed Nov 1, 2026 at 09:34 UTC 6bfeb4f8252f7054fcf1fbc11ec9b3846defc749
4 files changed +368 -10
modules/caddyhttp/caddyauth/basicauth.go
+19
@@ -280,6 +280,25 @@ type Hasher interface {
280 FakeHash() []byte
281 }
282
283 +// hashesNamespace is the module namespace of password hashing modules.
284 +const hashesNamespace = "http.authentication.hashes"
285 +
286 +// hashModuleInfo returns the module info of the installed password
287 +// hashing module with the given name (e.g. "bcrypt"). If no such module
288 +// is installed, the returned error lists the available algorithms.
289 +func hashModuleInfo(name string) (caddy.ModuleInfo, error) {
290 + mods := caddy.GetModules(hashesNamespace)
291 + names := make([]string, 0, len(mods))
292 + for _, mod := range mods {
293 + if mod.ID.Name() == name {
294 + return mod, nil
295 + }
296 + names = append(names, mod.ID.Name())
297 + }
298 + return caddy.ModuleInfo{}, fmt.Errorf("unrecognized hash algorithm: %s (available: %s)",
299 + name, strings.Join(names, ", "))
300 +}
301 +
302 // Account contains a username and password.
303 type Account struct {
304 // A user's username.
modules/caddyhttp/caddyauth/caddyfile.go
+9 -8
@@ -33,6 +33,8 @@ func init() {
33 // ...
34 // }
35 //
36 +// The hash algorithm is the name of any installed module in the
37 +// http.authentication.hashes namespace, such as bcrypt or argon2id.
38 // If no hash algorithm is supplied, bcrypt will be assumed.
39 func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) {
40 h.Next() // consume directive name
@@ -45,7 +47,6 @@ func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error)
47 var ba HTTPBasicAuth
48 ba.HashCache = new(Cache)
49
48 - var cmp Comparer
50 args := h.RemainingArgs()
51
52 var hashName string
@@ -61,13 +62,13 @@ func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error)
62 return nil, h.ArgErr()
63 }
64
64 - switch hashName {
65 - case bcryptName:
66 - cmp = BcryptHash{}
67 - case argon2idName:
68 - cmp = Argon2idHash{}
69 - default:
70 - return nil, h.Errf("unrecognized hash algorithm: %s", hashName)
65 + modInfo, err := hashModuleInfo(hashName)
66 + if err != nil {
67 + return nil, h.WrapErr(err)
68 + }
69 + cmp, ok := modInfo.New().(Comparer)
70 + if !ok {
71 + return nil, h.Errf("hash module %s is not a password comparer", modInfo.ID)
72 }
73
74 ba.HashRaw = caddyconfig.JSONModuleObject(cmp, "algorithm", hashName, nil)
modules/caddyhttp/caddyauth/command.go
+38 -2
@@ -17,6 +17,8 @@ package caddyauth
17 import (
18 "bufio"
19 "bytes"
20 + "context"
21 + "encoding/base64"
22 "errors"
23 "fmt"
24 "io"
@@ -34,7 +36,7 @@ import (
36 func init() {
37 caddycmd.RegisterCommand(caddycmd.Command{
38 Name: "hash-password",
37 - Usage: "[--plaintext <password>] [--algorithm <argon2id|bcrypt>] [--bcrypt-cost <difficulty>] [--argon2id-time <iterations>] [--argon2id-memory <KiB>] [--argon2id-threads <n>] [--argon2id-keylen <bytes>]",
39 + Usage: "[--plaintext <password>] [--algorithm <argon2id|bcrypt|...>] [--bcrypt-cost <difficulty>] [--argon2id-time <iterations>] [--argon2id-memory <KiB>] [--argon2id-threads <n>] [--argon2id-keylen <bytes>]",
40 Short: "Hashes a password and writes base64",
41 Long: `
42 Convenient way to hash a plaintext password. The resulting
@@ -48,6 +50,9 @@ hash is written to stdout as a base64 string.
50 Selects the hashing algorithm. Valid options are:
51 * 'argon2id' (recommended for modern security)
52 * 'bcrypt' (legacy, slower, configurable cost)
53 + * the name of another installed http.authentication.hashes
54 + module that can generate hashes with its default
55 + configuration; its output is base64-encoded
56
57 bcrypt-specific parameters:
58
@@ -178,7 +183,7 @@ func cmdHashPassword(fs caddycmd.Flags) (int, error) {
183
184 hashString = string(hash)
185 default:
181 - return caddy.ExitCodeFailedStartup, fmt.Errorf("unrecognized hash algorithm: %s", algorithm)
186 + hashString, err = hashWithModule(algorithm, plaintext)
187 }
188 if err != nil {
189 return caddy.ExitCodeFailedStartup, err
@@ -188,3 +193,34 @@ func cmdHashPassword(fs caddycmd.Flags) (int, error) {
193
194 return 0, nil
195 }
196 +
197 +// hashWithModule hashes plaintext using the installed password
198 +// hashing module with the given name, loaded with an empty
199 +// configuration. The module must implement Hasher to be usable for
200 +// generating hashes. Its output is arbitrary bytes, so it is always
201 +// base64-encoded, which is the form HTTPBasicAuth reads passwords in
202 +// when they do not start with "$".
203 +func hashWithModule(algorithm string, plaintext []byte) (string, error) {
204 + modInfo, err := hashModuleInfo(algorithm)
205 + if err != nil {
206 + return "", err
207 + }
208 +
209 + ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
210 + defer cancel()
211 +
212 + mod, err := ctx.LoadModuleByID(string(modInfo.ID), nil)
213 + if err != nil {
214 + return "", err
215 + }
216 + hasher, ok := mod.(Hasher)
217 + if !ok {
218 + return "", fmt.Errorf("hash algorithm %s does not support generating hashes", algorithm)
219 + }
220 +
221 + hash, err := hasher.Hash(plaintext)
222 + if err != nil {
223 + return "", err
224 + }
225 + return base64.StdEncoding.EncodeToString(hash), nil
226 +}
modules/caddyhttp/caddyauth/hashes_test.go new
+302
@@ -0,0 +1,302 @@
1 +// Copyright 2015 Matthew Holt and The Caddy Authors
2 +//
3 +// Licensed under the Apache License, Version 2.0 (the "License");
4 +// you may not use this file except in compliance with the License.
5 +// You may obtain a copy of the License at
6 +//
7 +// http://www.apache.org/licenses/LICENSE-2.0
8 +//
9 +// Unless required by applicable law or agreed to in writing, software
10 +// distributed under the License is distributed on an "AS IS" BASIS,
11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +// See the License for the specific language governing permissions and
13 +// limitations under the License.
14 +
15 +package caddyauth
16 +
17 +import (
18 + "bytes"
19 + "encoding/json"
20 + "errors"
21 + "io"
22 + "os"
23 + "strings"
24 + "testing"
25 +
26 + "github.com/spf13/pflag"
27 +
28 + "github.com/caddyserver/caddy/v2"
29 + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
30 + "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
31 + caddycmd "github.com/caddyserver/caddy/v2/cmd"
32 +)
33 +
34 +func init() {
35 + caddy.RegisterModule(testHash{})
36 + caddy.RegisterModule(testCompareOnlyHash{})
37 + caddy.RegisterModule(testMCFHash{})
38 +}
39 +
40 +// testHash is a third-party-style hash module used to
41 +// test that modules in the http.authentication.hashes
42 +// namespace can be used by name.
43 +type testHash struct {
44 + provisioned bool
45 +}
46 +
47 +func (testHash) CaddyModule() caddy.ModuleInfo {
48 + return caddy.ModuleInfo{
49 + ID: "http.authentication.hashes.testhash",
50 + New: func() caddy.Module { return new(testHash) },
51 + }
52 +}
53 +
54 +func (th *testHash) Provision(caddy.Context) error {
55 + th.provisioned = true
56 + return nil
57 +}
58 +
59 +func (testHash) Compare(hashed, plaintext []byte) (bool, error) {
60 + return bytes.Equal(hashed, append([]byte("testhash:"), plaintext...)), nil
61 +}
62 +
63 +func (th testHash) Hash(plaintext []byte) ([]byte, error) {
64 + if !th.provisioned {
65 + return nil, errors.New("testhash: not provisioned")
66 + }
67 + return append([]byte("testhash:"), plaintext...), nil
68 +}
69 +
70 +func (testHash) FakeHash() []byte { return []byte("testhash:fake") }
71 +
72 +// testCompareOnlyHash can compare, but not generate, hashes.
73 +type testCompareOnlyHash struct{}
74 +
75 +func (testCompareOnlyHash) CaddyModule() caddy.ModuleInfo {
76 + return caddy.ModuleInfo{
77 + ID: "http.authentication.hashes.testcompareonly",
78 + New: func() caddy.Module { return new(testCompareOnlyHash) },
79 + }
80 +}
81 +
82 +func (testCompareOnlyHash) Compare(hashed, plaintext []byte) (bool, error) {
83 + return bytes.Equal(hashed, plaintext), nil
84 +}
85 +
86 +// testMCFHash produces hashes that start with "$", like Modular Crypt Format.
87 +type testMCFHash struct{}
88 +
89 +func (testMCFHash) CaddyModule() caddy.ModuleInfo {
90 + return caddy.ModuleInfo{
91 + ID: "http.authentication.hashes.testmcf",
92 + New: func() caddy.Module { return new(testMCFHash) },
93 + }
94 +}
95 +
96 +func (testMCFHash) Compare(hashed, plaintext []byte) (bool, error) {
97 + return bytes.Equal(hashed, append([]byte("$testmcf$"), plaintext...)), nil
98 +}
99 +
100 +func (testMCFHash) Hash(plaintext []byte) ([]byte, error) {
101 + return append([]byte("$testmcf$"), plaintext...), nil
102 +}
103 +
104 +func (testMCFHash) FakeHash() []byte { return []byte("$testmcf$fake") }
105 +
106 +func adaptBasicAuth(t *testing.T, input string) (string, error) {
107 + t.Helper()
108 + adapter := caddyfile.Adapter{ServerType: httpcaddyfile.ServerType{}}
109 + result, _, err := adapter.Adapt([]byte(input), map[string]any{"filename": "Caddyfile"})
110 + if err != nil {
111 + return "", err
112 + }
113 +
114 + var cfg struct {
115 + Apps struct {
116 + HTTP struct {
117 + Servers map[string]struct {
118 + Routes []struct {
119 + Handle []struct {
120 + Providers struct {
121 + HTTPBasic struct {
122 + Hash struct {
123 + Algorithm string `json:"algorithm"`
124 + } `json:"hash"`
125 + } `json:"http_basic"`
126 + } `json:"providers"`
127 + } `json:"handle"`
128 + } `json:"routes"`
129 + } `json:"servers"`
130 + } `json:"http"`
131 + } `json:"apps"`
132 + }
133 + if err := json.Unmarshal(result, &cfg); err != nil {
134 + t.Fatalf("unmarshaling adapted config: %v\n%s", err, result)
135 + }
136 + srv, ok := cfg.Apps.HTTP.Servers["srv0"]
137 + if !ok || len(srv.Routes) != 1 || len(srv.Routes[0].Handle) != 1 {
138 + t.Fatalf("unexpected adapted config: %s", result)
139 + }
140 + return srv.Routes[0].Handle[0].Providers.HTTPBasic.Hash.Algorithm, nil
141 +}
142 +
143 +func TestBasicAuthCaddyfileHashModules(t *testing.T) {
144 + for _, tc := range []struct {
145 + name string
146 + algorithm string
147 + expected string
148 + }{
149 + {name: "default", algorithm: "", expected: "bcrypt"},
150 + {name: "bcrypt", algorithm: "bcrypt", expected: "bcrypt"},
151 + {name: "argon2id", algorithm: "argon2id", expected: "argon2id"},
152 + {name: "third-party", algorithm: "testhash", expected: "testhash"},
153 + {name: "third-party compare only", algorithm: "testcompareonly", expected: "testcompareonly"},
154 + } {
155 + t.Run(tc.name, func(t *testing.T) {
156 + input := ":80 {\n\tbasic_auth " + tc.algorithm + " {\n\t\talice dGVzdGhhc2g6c2VjcmV0\n\t}\n}\n"
157 + algorithm, err := adaptBasicAuth(t, input)
158 + if err != nil {
159 + t.Fatalf("unexpected error: %v", err)
160 + }
161 + if algorithm != tc.expected {
162 + t.Errorf("expected hash algorithm %q, got %q", tc.expected, algorithm)
163 + }
164 + })
165 + }
166 +}
167 +
168 +func TestBasicAuthCaddyfileUnknownHash(t *testing.T) {
169 + _, err := adaptBasicAuth(t, ":80 {\n\tbasic_auth nope {\n\t\talice c2VjcmV0\n\t}\n}\n")
170 + if err == nil {
171 + t.Fatal("expected an error for an unknown hash algorithm, got nil")
172 + }
173 + for _, want := range []string{"unrecognized hash algorithm: nope", "argon2id", "bcrypt", "testhash"} {
174 + if !strings.Contains(err.Error(), want) {
175 + t.Errorf("expected error to contain %q, got: %v", want, err)
176 + }
177 + }
178 +}
179 +
180 +func TestBasicAuthProvisionHashModule(t *testing.T) {
181 + ctx, cancel := caddy.NewContext(caddy.Context{Context: t.Context()})
182 + defer cancel()
183 +
184 + hba := HTTPBasicAuth{
185 + HashRaw: json.RawMessage(`{"algorithm": "testhash"}`),
186 + AccountList: []Account{{
187 + Username: "alice",
188 + Password: "dGVzdGhhc2g6c2VjcmV0", // base64("testhash:secret")
189 + }},
190 + HashCache: new(Cache),
191 + }
192 + if err := hba.Provision(ctx); err != nil {
193 + t.Fatalf("provisioning: %v", err)
194 + }
195 + if _, ok := hba.Hash.(*testHash); !ok {
196 + t.Fatalf("expected hash module to be *testHash, got %T", hba.Hash)
197 + }
198 + if string(hba.fakePassword) != "testhash:fake" {
199 + t.Errorf("expected fake password from module, got %q", hba.fakePassword)
200 + }
201 + if ok, err := hba.correctPassword(hba.Accounts["alice"], []byte("secret")); err != nil || !ok {
202 + t.Errorf("expected password to match (err=%v)", err)
203 + }
204 + if ok, err := hba.correctPassword(hba.Accounts["alice"], []byte("wrong")); err != nil || ok {
205 + t.Errorf("expected password not to match (err=%v)", err)
206 + }
207 +}
208 +
209 +func runHashPassword(t *testing.T, algorithm, plaintext string) (string, error) {
210 + t.Helper()
211 +
212 + fs := pflag.NewFlagSet("hash-password", pflag.ContinueOnError)
213 + fs.String("plaintext", plaintext, "")
214 + fs.String("algorithm", algorithm, "")
215 + fs.Int("bcrypt-cost", defaultBcryptCost, "")
216 + fs.Uint32("argon2id-time", defaultArgon2idTime, "")
217 + fs.Uint32("argon2id-memory", defaultArgon2idMemory, "")
218 + fs.Uint8("argon2id-threads", defaultArgon2idThreads, "")
219 + fs.Uint32("argon2id-keylen", defaultArgon2idKeylen, "")
220 +
221 + r, w, err := os.Pipe()
222 + if err != nil {
223 + t.Fatal(err)
224 + }
225 + stdout := os.Stdout
226 + os.Stdout = w
227 + _, cmdErr := cmdHashPassword(caddycmd.Flags{FlagSet: fs})
228 + os.Stdout = stdout
229 + _ = w.Close()
230 +
231 + out, err := io.ReadAll(r)
232 + if err != nil {
233 + t.Fatal(err)
234 + }
235 + return strings.TrimSpace(string(out)), cmdErr
236 +}
237 +
238 +func TestHashPasswordCommandHashModule(t *testing.T) {
239 + out, err := runHashPassword(t, "testhash", "secret")
240 + if err != nil {
241 + t.Fatalf("unexpected error: %v", err)
242 + }
243 + if out != "dGVzdGhhc2g6c2VjcmV0" { // base64("testhash:secret")
244 + t.Errorf("expected output %q, got %q", "dGVzdGhhc2g6c2VjcmV0", out)
245 + }
246 +
247 + out, err = runHashPassword(t, "testmcf", "secret")
248 + if err != nil {
249 + t.Fatalf("unexpected error: %v", err)
250 + }
251 + // A leading "$" does not make a third-party hash MCF, so it is
252 + // base64-encoded like any other module output.
253 + if out != "JHRlc3RtY2Ykc2VjcmV0" { // base64("$testmcf$secret")
254 + t.Errorf("expected output %q, got %q", "JHRlc3RtY2Ykc2VjcmV0", out)
255 + }
256 +}
257 +
258 +// TestHashPasswordCommandRoundTrip checks that the output of the
259 +// hash-password command is accepted by HTTPBasicAuth as an account password.
260 +func TestHashPasswordCommandRoundTrip(t *testing.T) {
261 + for _, algorithm := range []string{"testhash", "testmcf", "bcrypt", "argon2id"} {
262 + t.Run(algorithm, func(t *testing.T) {
263 + out, err := runHashPassword(t, algorithm, "secret")
264 + if err != nil {
265 + t.Fatalf("hash-password: %v", err)
266 + }
267 +
268 + ctx, cancel := caddy.NewContext(caddy.Context{Context: t.Context()})
269 + defer cancel()
270 + hba := HTTPBasicAuth{
271 + HashRaw: json.RawMessage(`{"algorithm": "` + algorithm + `"}`),
272 + AccountList: []Account{{Username: "alice", Password: out}},
273 + }
274 + if err := hba.Provision(ctx); err != nil {
275 + t.Fatalf("provisioning with %q: %v", out, err)
276 + }
277 + if ok, err := hba.correctPassword(hba.Accounts["alice"], []byte("secret")); err != nil || !ok {
278 + t.Errorf("expected password to match (err=%v)", err)
279 + }
280 + if ok, err := hba.correctPassword(hba.Accounts["alice"], []byte("wrong")); err != nil || ok {
281 + t.Errorf("expected wrong password not to match (err=%v)", err)
282 + }
283 + })
284 + }
285 +}
286 +
287 +func TestHashPasswordCommandErrors(t *testing.T) {
288 + _, err := runHashPassword(t, "nope", "secret")
289 + if err == nil {
290 + t.Fatal("expected an error for an unknown hash algorithm, got nil")
291 + }
292 + for _, want := range []string{"unrecognized hash algorithm: nope", "argon2id", "bcrypt", "testhash"} {
293 + if !strings.Contains(err.Error(), want) {
294 + t.Errorf("expected error to contain %q, got: %v", want, err)
295 + }
296 + }
297 +
298 + _, err = runHashPassword(t, "testcompareonly", "secret")
299 + if err == nil || !strings.Contains(err.Error(), "does not support generating hashes") {
300 + t.Errorf("expected an error for a hash module that cannot hash, got: %v", err)
301 + }
302 +}