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
+}