├── .github
└── workflows
│ └── test.yml
├── .gitignore
├── LICENSE
├── Makefile
├── README.md
├── auth.go
├── basic.go
├── basic_test.go
├── digest.go
├── digest_test.go
├── examples
├── basic.go
├── context.go
├── digest.go
└── wrapped.go
├── go.mod
├── go.sum
├── md5crypt.go
├── md5crypt_test.go
├── misc.go
├── misc_test.go
├── test.htdigest
├── test.htpasswd
├── users.go
└── users_test.go
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Build and Test
2 |
3 | on:
4 | push:
5 | branches: [ master ]
6 | pull_request:
7 | branches: [ master ]
8 | schedule:
9 | - cron: '16 3 * * 1'
10 |
11 | jobs:
12 | build:
13 | runs-on: ubuntu-latest
14 | strategy:
15 | fail-fast: true
16 | matrix:
17 | go_version: ['1.14', '1.17', '>=1.17']
18 |
19 | steps:
20 | - uses: actions/checkout@v2
21 | - name: Set up Go ${{matrix.go_version}}
22 | uses: actions/setup-go@v2
23 | with:
24 | go-version: ${{ matrix.go_version }}
25 | - name: Build
26 | run: go build -v ./...
27 | - name: Test
28 | run: go test -v ./...
29 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *~
2 | *.a
3 | *.6
4 | *.out
5 | _testmain.go
6 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 |
2 | Apache License
3 | Version 2.0, January 2004
4 | http://www.apache.org/licenses/
5 |
6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7 |
8 | 1. Definitions.
9 |
10 | "License" shall mean the terms and conditions for use, reproduction,
11 | and distribution as defined by Sections 1 through 9 of this document.
12 |
13 | "Licensor" shall mean the copyright owner or entity authorized by
14 | the copyright owner that is granting the License.
15 |
16 | "Legal Entity" shall mean the union of the acting entity and all
17 | other entities that control, are controlled by, or are under common
18 | control with that entity. For the purposes of this definition,
19 | "control" means (i) the power, direct or indirect, to cause the
20 | direction or management of such entity, whether by contract or
21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
22 | outstanding shares, or (iii) beneficial ownership of such entity.
23 |
24 | "You" (or "Your") shall mean an individual or Legal Entity
25 | exercising permissions granted by this License.
26 |
27 | "Source" form shall mean the preferred form for making modifications,
28 | including but not limited to software source code, documentation
29 | source, and configuration files.
30 |
31 | "Object" form shall mean any form resulting from mechanical
32 | transformation or translation of a Source form, including but
33 | not limited to compiled object code, generated documentation,
34 | and conversions to other media types.
35 |
36 | "Work" shall mean the work of authorship, whether in Source or
37 | Object form, made available under the License, as indicated by a
38 | copyright notice that is included in or attached to the work
39 | (an example is provided in the Appendix below).
40 |
41 | "Derivative Works" shall mean any work, whether in Source or Object
42 | form, that is based on (or derived from) the Work and for which the
43 | editorial revisions, annotations, elaborations, or other modifications
44 | represent, as a whole, an original work of authorship. For the purposes
45 | of this License, Derivative Works shall not include works that remain
46 | separable from, or merely link (or bind by name) to the interfaces of,
47 | the Work and Derivative Works thereof.
48 |
49 | "Contribution" shall mean any work of authorship, including
50 | the original version of the Work and any modifications or additions
51 | to that Work or Derivative Works thereof, that is intentionally
52 | submitted to Licensor for inclusion in the Work by the copyright owner
53 | or by an individual or Legal Entity authorized to submit on behalf of
54 | the copyright owner. For the purposes of this definition, "submitted"
55 | means any form of electronic, verbal, or written communication sent
56 | to the Licensor or its representatives, including but not limited to
57 | communication on electronic mailing lists, source code control systems,
58 | and issue tracking systems that are managed by, or on behalf of, the
59 | Licensor for the purpose of discussing and improving the Work, but
60 | excluding communication that is conspicuously marked or otherwise
61 | designated in writing by the copyright owner as "Not a Contribution."
62 |
63 | "Contributor" shall mean Licensor and any individual or Legal Entity
64 | on behalf of whom a Contribution has been received by Licensor and
65 | subsequently incorporated within the Work.
66 |
67 | 2. Grant of Copyright License. Subject to the terms and conditions of
68 | this License, each Contributor hereby grants to You a perpetual,
69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70 | copyright license to reproduce, prepare Derivative Works of,
71 | publicly display, publicly perform, sublicense, and distribute the
72 | Work and such Derivative Works in Source or Object form.
73 |
74 | 3. Grant of Patent License. Subject to the terms and conditions of
75 | this License, each Contributor hereby grants to You a perpetual,
76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77 | (except as stated in this section) patent license to make, have made,
78 | use, offer to sell, sell, import, and otherwise transfer the Work,
79 | where such license applies only to those patent claims licensable
80 | by such Contributor that are necessarily infringed by their
81 | Contribution(s) alone or by combination of their Contribution(s)
82 | with the Work to which such Contribution(s) was submitted. If You
83 | institute patent litigation against any entity (including a
84 | cross-claim or counterclaim in a lawsuit) alleging that the Work
85 | or a Contribution incorporated within the Work constitutes direct
86 | or contributory patent infringement, then any patent licenses
87 | granted to You under this License for that Work shall terminate
88 | as of the date such litigation is filed.
89 |
90 | 4. Redistribution. You may reproduce and distribute copies of the
91 | Work or Derivative Works thereof in any medium, with or without
92 | modifications, and in Source or Object form, provided that You
93 | meet the following conditions:
94 |
95 | (a) You must give any other recipients of the Work or
96 | Derivative Works a copy of this License; and
97 |
98 | (b) You must cause any modified files to carry prominent notices
99 | stating that You changed the files; and
100 |
101 | (c) You must retain, in the Source form of any Derivative Works
102 | that You distribute, all copyright, patent, trademark, and
103 | attribution notices from the Source form of the Work,
104 | excluding those notices that do not pertain to any part of
105 | the Derivative Works; and
106 |
107 | (d) If the Work includes a "NOTICE" text file as part of its
108 | distribution, then any Derivative Works that You distribute must
109 | include a readable copy of the attribution notices contained
110 | within such NOTICE file, excluding those notices that do not
111 | pertain to any part of the Derivative Works, in at least one
112 | of the following places: within a NOTICE text file distributed
113 | as part of the Derivative Works; within the Source form or
114 | documentation, if provided along with the Derivative Works; or,
115 | within a display generated by the Derivative Works, if and
116 | wherever such third-party notices normally appear. The contents
117 | of the NOTICE file are for informational purposes only and
118 | do not modify the License. You may add Your own attribution
119 | notices within Derivative Works that You distribute, alongside
120 | or as an addendum to the NOTICE text from the Work, provided
121 | that such additional attribution notices cannot be construed
122 | as modifying the License.
123 |
124 | You may add Your own copyright statement to Your modifications and
125 | may provide additional or different license terms and conditions
126 | for use, reproduction, or distribution of Your modifications, or
127 | for any such Derivative Works as a whole, provided Your use,
128 | reproduction, and distribution of the Work otherwise complies with
129 | the conditions stated in this License.
130 |
131 | 5. Submission of Contributions. Unless You explicitly state otherwise,
132 | any Contribution intentionally submitted for inclusion in the Work
133 | by You to the Licensor shall be under the terms and conditions of
134 | this License, without any additional terms or conditions.
135 | Notwithstanding the above, nothing herein shall supersede or modify
136 | the terms of any separate license agreement you may have executed
137 | with Licensor regarding such Contributions.
138 |
139 | 6. Trademarks. This License does not grant permission to use the trade
140 | names, trademarks, service marks, or product names of the Licensor,
141 | except as required for reasonable and customary use in describing the
142 | origin of the Work and reproducing the content of the NOTICE file.
143 |
144 | 7. Disclaimer of Warranty. Unless required by applicable law or
145 | agreed to in writing, Licensor provides the Work (and each
146 | Contributor provides its Contributions) on an "AS IS" BASIS,
147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148 | implied, including, without limitation, any warranties or conditions
149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150 | PARTICULAR PURPOSE. You are solely responsible for determining the
151 | appropriateness of using or redistributing the Work and assume any
152 | risks associated with Your exercise of permissions under this License.
153 |
154 | 8. Limitation of Liability. In no event and under no legal theory,
155 | whether in tort (including negligence), contract, or otherwise,
156 | unless required by applicable law (such as deliberate and grossly
157 | negligent acts) or agreed to in writing, shall any Contributor be
158 | liable to You for damages, including any direct, indirect, special,
159 | incidental, or consequential damages of any character arising as a
160 | result of this License or out of the use or inability to use the
161 | Work (including but not limited to damages for loss of goodwill,
162 | work stoppage, computer failure or malfunction, or any and all
163 | other commercial damages or losses), even if such Contributor
164 | has been advised of the possibility of such damages.
165 |
166 | 9. Accepting Warranty or Additional Liability. While redistributing
167 | the Work or Derivative Works thereof, You may choose to offer,
168 | and charge a fee for, acceptance of support, warranty, indemnity,
169 | or other liability obligations and/or rights consistent with this
170 | License. However, in accepting such obligations, You may act only
171 | on Your own behalf and on Your sole responsibility, not on behalf
172 | of any other Contributor, and only if You agree to indemnify,
173 | defend, and hold each Contributor harmless for any liability
174 | incurred by, or claims asserted against, such Contributor by reason
175 | of your accepting any such warranty or additional liability.
176 |
177 | END OF TERMS AND CONDITIONS
178 |
179 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | include $(GOROOT)/src/Make.inc
2 |
3 | TARG=auth_digest
4 | GOFILES=\
5 | auth.go\
6 | digest.go\
7 | basic.go\
8 | misc.go\
9 | md5crypt.go\
10 | users.go\
11 |
12 | include $(GOROOT)/src/Make.pkg
13 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | HTTP Authentication implementation in Go
2 | ========================================
3 |
4 | This is an implementation of HTTP Basic and HTTP Digest authentication
5 | in Go language. It is designed as a simple wrapper for
6 | http.RequestHandler functions.
7 |
8 | Features
9 | --------
10 |
11 | * Supports HTTP Basic and HTTP Digest authentication.
12 | * Supports htpasswd and htdigest formatted files.
13 | * Automatic reloading of password files.
14 | * Pluggable interface for user/password storage.
15 | * Supports MD5, SHA1 and BCrypt for Basic authentication password storage.
16 | * Configurable Digest nonce cache size with expiration.
17 | * Wrapper for legacy http handlers (http.HandlerFunc interface)
18 |
19 | Example usage
20 | -------------
21 |
22 | This is a complete working example for Basic auth:
23 |
24 | package main
25 |
26 | import (
27 | "fmt"
28 | "net/http"
29 |
30 | auth "github.com/abbot/go-http-auth"
31 | )
32 |
33 | func Secret(user, realm string) string {
34 | if user == "john" {
35 | // password is "hello"
36 | return "$1$dlPL2MqE$oQmn16q49SqdmhenQuNgs1"
37 | }
38 | return ""
39 | }
40 |
41 | func handle(w http.ResponseWriter, r *auth.AuthenticatedRequest) {
42 | fmt.Fprintf(w, "
Hello, %s!
", r.Username)
43 | }
44 |
45 | func main() {
46 | authenticator := auth.NewBasicAuthenticator("example.com", Secret)
47 | http.HandleFunc("/", authenticator.Wrap(handle))
48 | http.ListenAndServe(":8080", nil)
49 | }
50 |
51 | See more examples in the "examples" directory.
52 |
53 | Legal
54 | -----
55 |
56 | This module is developed under Apache 2.0 license, and can be used for
57 | open and proprietary projects.
58 |
59 | Copyright 2012-2013 Lev Shamardin
60 |
61 | Licensed under the Apache License, Version 2.0 (the "License"); you
62 | may not use this file or any other part of this project except in
63 | compliance with the License. You may obtain a copy of the License at
64 |
65 | http://www.apache.org/licenses/LICENSE-2.0
66 |
67 | Unless required by applicable law or agreed to in writing, software
68 | distributed under the License is distributed on an "AS IS" BASIS,
69 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
70 | implied. See the License for the specific language governing
71 | permissions and limitations under the License.
72 |
--------------------------------------------------------------------------------
/auth.go:
--------------------------------------------------------------------------------
1 | // Package auth is an implementation of HTTP Basic and HTTP Digest authentication.
2 | package auth
3 |
4 | import (
5 | "context"
6 | "net/http"
7 | )
8 |
9 | // AuthenticatedRequest is passed to AuthenticatedHandlerFunc instead
10 | // of *http.Request.
11 | type AuthenticatedRequest struct {
12 | http.Request
13 | // Username is the authenticated user name. Current API implies that
14 | // Username is never empty, which means that authentication is
15 | // always done before calling the request handler.
16 | Username string
17 | }
18 |
19 | // AuthenticatedHandlerFunc is like http.HandlerFunc, but takes
20 | // AuthenticatedRequest instead of http.Request
21 | type AuthenticatedHandlerFunc func(http.ResponseWriter, *AuthenticatedRequest)
22 |
23 | // Authenticator wraps an AuthenticatedHandlerFunc with
24 | // authentication-checking code.
25 | //
26 | // Typical Authenticator usage is something like:
27 | //
28 | // authenticator := SomeAuthenticator(...)
29 | // http.HandleFunc("/", authenticator(my_handler))
30 | //
31 | // Authenticator wrapper checks the user authentication and calls the
32 | // wrapped function only after authentication has succeeded. Otherwise,
33 | // it returns a handler which initiates the authentication procedure.
34 | type Authenticator func(AuthenticatedHandlerFunc) http.HandlerFunc
35 |
36 | // Info contains authentication information for the request.
37 | type Info struct {
38 | // Authenticated is set to true when request was authenticated
39 | // successfully, i.e. username and password passed in request did
40 | // pass the check.
41 | Authenticated bool
42 |
43 | // Username contains a user name passed in the request when
44 | // Authenticated is true. It's value is undefined if Authenticated
45 | // is false.
46 | Username string
47 |
48 | // ResponseHeaders contains extra headers that must be set by server
49 | // when sending back HTTP response.
50 | ResponseHeaders http.Header
51 | }
52 |
53 | // UpdateHeaders updates headers with this Info's ResponseHeaders. It is
54 | // safe to call this function on nil Info.
55 | func (i *Info) UpdateHeaders(headers http.Header) {
56 | if i == nil {
57 | return
58 | }
59 | for k, values := range i.ResponseHeaders {
60 | for _, v := range values {
61 | headers.Add(k, v)
62 | }
63 | }
64 | }
65 |
66 | type key int // used for context keys
67 |
68 | var infoKey key
69 |
70 | // AuthenticatorInterface is the interface implemented by BasicAuth
71 | // and DigestAuth authenticators.
72 | //
73 | // Deprecated: this interface is not coherent. New code should define
74 | // and use your own interfaces with a required subset of authenticator
75 | // methods.
76 | type AuthenticatorInterface interface {
77 | // NewContext returns a new context carrying authentication
78 | // information extracted from the request.
79 | NewContext(ctx context.Context, r *http.Request) context.Context
80 |
81 | // Wrap returns an http.HandlerFunc which wraps
82 | // AuthenticatedHandlerFunc with this authenticator's
83 | // authentication checks.
84 | Wrap(AuthenticatedHandlerFunc) http.HandlerFunc
85 | }
86 |
87 | // FromContext returns authentication information from the context or
88 | // nil if no such information present.
89 | func FromContext(ctx context.Context) *Info {
90 | info, ok := ctx.Value(infoKey).(*Info)
91 | if !ok {
92 | return nil
93 | }
94 | return info
95 | }
96 |
97 | // AuthUsernameHeader is the header set by JustCheck functions. It
98 | // contains an authenticated username (if authentication was
99 | // successful).
100 | const AuthUsernameHeader = "X-Authenticated-Username"
101 |
102 | // JustCheck returns a new http.HandlerFunc, which requires
103 | // authenticator to successfully authenticate a user before calling
104 | // wrapped http.HandlerFunc.
105 | func JustCheck(auth AuthenticatorInterface, wrapped http.HandlerFunc) http.HandlerFunc {
106 | return auth.Wrap(func(w http.ResponseWriter, ar *AuthenticatedRequest) {
107 | ar.Header.Set(AuthUsernameHeader, ar.Username)
108 | wrapped(w, &ar.Request)
109 | })
110 | }
111 |
--------------------------------------------------------------------------------
/basic.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "crypto/sha1"
7 | "crypto/subtle"
8 | "encoding/base64"
9 | "errors"
10 | "net/http"
11 | "strings"
12 |
13 | "golang.org/x/crypto/bcrypt"
14 | )
15 |
16 | type compareFunc func(hashedPassword, password []byte) error
17 |
18 | var (
19 | errMismatchedHashAndPassword = errors.New("mismatched hash and password")
20 |
21 | compareFuncs = []struct {
22 | prefix string
23 | compare compareFunc
24 | }{
25 | {"", compareMD5HashAndPassword}, // default compareFunc
26 | {"{SHA}", compareShaHashAndPassword},
27 | // Bcrypt is complicated. According to crypt(3) from
28 | // crypt_blowfish version 1.3 (fetched from
29 | // http://www.openwall.com/crypt/crypt_blowfish-1.3.tar.gz), there
30 | // are three different has prefixes: "$2a$", used by versions up
31 | // to 1.0.4, and "$2x$" and "$2y$", used in all later
32 | // versions. "$2a$" has a known bug, "$2x$" was added as a
33 | // migration path for systems with "$2a$" prefix and still has a
34 | // bug, and only "$2y$" should be used by modern systems. The bug
35 | // has something to do with handling of 8-bit characters. Since
36 | // both "$2a$" and "$2x$" are deprecated, we are handling them the
37 | // same way as "$2y$", which will yield correct results for 7-bit
38 | // character passwords, but is wrong for 8-bit character
39 | // passwords. You have to upgrade to "$2y$" if you want sant 8-bit
40 | // character password support with bcrypt. To add to the mess,
41 | // OpenBSD 5.5. introduced "$2b$" prefix, which behaves exactly
42 | // like "$2y$" according to the same source.
43 | {"$2a$", bcrypt.CompareHashAndPassword},
44 | {"$2b$", bcrypt.CompareHashAndPassword},
45 | {"$2x$", bcrypt.CompareHashAndPassword},
46 | {"$2y$", bcrypt.CompareHashAndPassword},
47 | }
48 | )
49 |
50 | // BasicAuth is an authenticator implementation for 'Basic' HTTP
51 | // Authentication scheme (RFC 7617).
52 | type BasicAuth struct {
53 | Realm string
54 | Secrets SecretProvider
55 | // Headers used by authenticator. Set to ProxyHeaders to use with
56 | // proxy server. When nil, NormalHeaders are used.
57 | Headers *Headers
58 | }
59 |
60 | // check that BasicAuth implements AuthenticatorInterface
61 | var _ = (AuthenticatorInterface)((*BasicAuth)(nil))
62 |
63 | // CheckAuth checks the username/password combination from the
64 | // request. Returns either an empty string (authentication failed) or
65 | // the name of the authenticated user.
66 | func (a *BasicAuth) CheckAuth(r *http.Request) string {
67 | user, password, ok := r.BasicAuth()
68 | if !ok {
69 | return ""
70 | }
71 |
72 | secret := a.Secrets(user, a.Realm)
73 | if secret == "" {
74 | return ""
75 | }
76 |
77 | if !CheckSecret(password, secret) {
78 | return ""
79 | }
80 |
81 | return user
82 | }
83 |
84 | // CheckSecret returns true if the password matches the encrypted
85 | // secret.
86 | func CheckSecret(password, secret string) bool {
87 | compare := compareFuncs[0].compare
88 | for _, cmp := range compareFuncs[1:] {
89 | if strings.HasPrefix(secret, cmp.prefix) {
90 | compare = cmp.compare
91 | break
92 | }
93 | }
94 | return compare([]byte(secret), []byte(password)) == nil
95 | }
96 |
97 | func compareShaHashAndPassword(hashedPassword, password []byte) error {
98 | d := sha1.New()
99 | d.Write(password)
100 | if subtle.ConstantTimeCompare(hashedPassword[5:], []byte(base64.StdEncoding.EncodeToString(d.Sum(nil)))) != 1 {
101 | return errMismatchedHashAndPassword
102 | }
103 | return nil
104 | }
105 |
106 | func compareMD5HashAndPassword(hashedPassword, password []byte) error {
107 | parts := bytes.SplitN(hashedPassword, []byte("$"), 4)
108 | if len(parts) != 4 {
109 | return errMismatchedHashAndPassword
110 | }
111 | magic := []byte("$" + string(parts[1]) + "$")
112 | salt := parts[2]
113 | if subtle.ConstantTimeCompare(hashedPassword, MD5Crypt(password, salt, magic)) != 1 {
114 | return errMismatchedHashAndPassword
115 | }
116 | return nil
117 | }
118 |
119 | // RequireAuth is an http.HandlerFunc for BasicAuth which initiates
120 | // the authentication process (or requires reauthentication).
121 | func (a *BasicAuth) RequireAuth(w http.ResponseWriter, r *http.Request) {
122 | w.Header().Set(contentType, a.Headers.V().UnauthContentType)
123 | w.Header().Set(a.Headers.V().Authenticate, `Basic realm="`+a.Realm+`"`)
124 | w.WriteHeader(a.Headers.V().UnauthCode)
125 | w.Write([]byte(a.Headers.V().UnauthResponse))
126 | }
127 |
128 | // Wrap returns an http.HandlerFunc, which wraps
129 | // AuthenticatedHandlerFunc with this BasicAuth authenticator's
130 | // authentication checks. Once the request contains valid credentials,
131 | // it calls wrapped AuthenticatedHandlerFunc.
132 | //
133 | // Deprecated: new code should use NewContext instead.
134 | func (a *BasicAuth) Wrap(wrapped AuthenticatedHandlerFunc) http.HandlerFunc {
135 | return func(w http.ResponseWriter, r *http.Request) {
136 | if username := a.CheckAuth(r); username == "" {
137 | a.RequireAuth(w, r)
138 | } else {
139 | ar := &AuthenticatedRequest{Request: *r, Username: username}
140 | wrapped(w, ar)
141 | }
142 | }
143 | }
144 |
145 | // NewContext returns a context carrying authentication information for the request.
146 | func (a *BasicAuth) NewContext(ctx context.Context, r *http.Request) context.Context {
147 | info := &Info{Username: a.CheckAuth(r), ResponseHeaders: make(http.Header)}
148 | info.Authenticated = (info.Username != "")
149 | if !info.Authenticated {
150 | info.ResponseHeaders.Set(a.Headers.V().Authenticate, `Basic realm="`+a.Realm+`"`)
151 | }
152 | return context.WithValue(ctx, infoKey, info)
153 | }
154 |
155 | // NewBasicAuthenticator returns a BasicAuth initialized with provided
156 | // realm and secrets.
157 | //
158 | // Deprecated: new code should construct BasicAuth values directly.
159 | func NewBasicAuthenticator(realm string, secrets SecretProvider) *BasicAuth {
160 | return &BasicAuth{Realm: realm, Secrets: secrets}
161 | }
162 |
--------------------------------------------------------------------------------
/basic_test.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 | "net/http/httptest"
7 | "testing"
8 | )
9 |
10 | var basicSecrets = map[string]string{
11 | "test": "{SHA}qvTGHdzF6KLavt4PO0gs2a6pQ00=",
12 | "test2": "$apr1$a0j62R97$mYqFkloXH0/UOaUnAiV2b0",
13 | "test16": "$apr1$JI4wh3am$AmhephVqLTUyAVpFQeHZC0",
14 | "test3": "$2y$05$ih3C91zUBSTFcAh2mQnZYuob0UOZVEf16wl/ukgjDhjvj.xgM1WwS",
15 | "testsha": "{SHA}qvTGHdzF6KLavt4PO0gs2a6pQ00=",
16 | "testmd5": "$apr1$0.KbAJur$4G9MiqUjDLCuihkMfmg6e1",
17 | "testmd5broken": "$apr10.KbAJur$4G9MiqUjDLCuihkMfmg6e1",
18 | }
19 |
20 | type credentials struct {
21 | username, password string
22 | }
23 |
24 | var basicCredentials = []credentials{
25 | {"test", "hello"},
26 | {"test2", "hello2"},
27 | {"test3", "hello3"},
28 | {"test16", "topsecret"},
29 | }
30 |
31 | func basicProvider(user, realm string) string {
32 | return basicSecrets[user]
33 | }
34 |
35 | func TestBasicCheckAuthFailsOnBadHeaders(t *testing.T) {
36 | t.Parallel()
37 | a := &BasicAuth{Realm: "example.com", Secrets: basicProvider}
38 | for _, auth := range []string{
39 | "",
40 | "Digest blabla ololo",
41 | "Basic !@#",
42 | } {
43 | r, err := http.NewRequest("GET", "http://example.com", nil)
44 | if err != nil {
45 | t.Fatal(err)
46 | }
47 | if auth != "" {
48 | r.Header.Set("Authorization", auth)
49 | }
50 | if a.CheckAuth(r) != "" {
51 | t.Errorf("CheckAuth returned a username for Authorization header %q", r.Header.Get("Authorization"))
52 | }
53 | }
54 | }
55 |
56 | func TestBasicCheckAuth(t *testing.T) {
57 | t.Parallel()
58 | a := &BasicAuth{Realm: "example.com", Secrets: basicProvider}
59 | for _, creds := range basicCredentials {
60 | r, err := http.NewRequest("GET", "http://example.com", nil)
61 | if err != nil {
62 | t.Fatal(err)
63 | }
64 | r.SetBasicAuth(creds.username, creds.password)
65 | if a.CheckAuth(r) != creds.username {
66 | t.Fatalf("CheckAuth failed for user '%s'", creds.username)
67 | }
68 | }
69 | }
70 |
71 | func TestBasicAuthContext(t *testing.T) {
72 | t.Parallel()
73 | a := &BasicAuth{Realm: "example.com", Secrets: basicProvider}
74 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
75 | ctx := a.NewContext(r.Context(), r)
76 | authInfo := FromContext(ctx)
77 | authInfo.UpdateHeaders(w.Header())
78 | if authInfo == nil || !authInfo.Authenticated {
79 | http.Error(w, "error", http.StatusUnauthorized)
80 | return
81 | }
82 | fmt.Fprint(w, authInfo.Username)
83 | }))
84 | defer ts.Close()
85 | for _, tt := range []struct {
86 | username, password string
87 | want int
88 | }{
89 | {"", "", http.StatusUnauthorized},
90 | {"test", "hello", http.StatusOK},
91 | {"testmd5", "hello", http.StatusOK},
92 | {"testmd5broken", "hello", http.StatusUnauthorized},
93 | {"testmd5", "invalid", http.StatusUnauthorized},
94 | } {
95 | r, err := http.NewRequest("GET", ts.URL, nil)
96 | if err != nil {
97 | t.Fatal(err)
98 | }
99 | r.SetBasicAuth(tt.username, tt.password)
100 | resp, err := http.DefaultClient.Do(r)
101 | if err != nil {
102 | t.Fatalf("HTTP request failed: %v", err)
103 | }
104 | if resp.StatusCode != tt.want {
105 | t.Errorf("user %q, password %q: got status %d, want %d", tt.username, tt.password, resp.StatusCode, tt.want)
106 | }
107 | }
108 | }
109 |
110 | func TestBasicAuthWrap(t *testing.T) {
111 | t.Parallel()
112 | a := NewBasicAuthenticator("example.com", basicProvider)
113 | ts := httptest.NewServer(http.HandlerFunc(a.Wrap(func(w http.ResponseWriter, r *AuthenticatedRequest) {
114 | if r.Username == "" {
115 | http.Error(w, "error", http.StatusUnauthorized)
116 | return
117 | }
118 | fmt.Fprint(w, r.Username)
119 | })))
120 | defer ts.Close()
121 | for _, tt := range []struct {
122 | username, password string
123 | want int
124 | }{
125 | {"", "", http.StatusUnauthorized},
126 | {"testsha", "invalid", http.StatusUnauthorized},
127 | {"testsha", "hello", http.StatusOK},
128 | } {
129 | r, err := http.NewRequest("GET", ts.URL, nil)
130 | if err != nil {
131 | t.Fatal(err)
132 | }
133 | r.SetBasicAuth(tt.username, tt.password)
134 | resp, err := http.DefaultClient.Do(r)
135 | if err != nil {
136 | t.Fatalf("HTTP request failed: %v", err)
137 | }
138 | if resp.StatusCode != tt.want {
139 | t.Errorf("user %q, password %q: got status %d, want %d", tt.username, tt.password, resp.StatusCode, tt.want)
140 | }
141 | }
142 | }
143 |
144 | func TestCheckSecret(t *testing.T) {
145 | t.Parallel()
146 | // alike cases are tested in users_test.go
147 | data := [][]string{
148 | // generated by htpasswd=2.4.18 and openssl=1.0.2g
149 | {"htpasswd-md5", "$apr1$FVVioVP7$ZdIWPG1p4E/ErujO7kA2n0"},
150 | {"openssl-apr1", "$apr1$peiE49Vv$lo.z77Z.6.a.Lm7GMjzQh0"},
151 | {"openssl-md5", "$1$mvmz31IB$U9KpHBLegga2doA0e3s3N0"},
152 | {"htpasswd-sha", "{SHA}vFznddje0Ht4+pmO0FaxwrUKN/M="},
153 | {"htpasswd-bcrypt", "$2y$10$Q6GeMFPd0dAxhQULPDdAn.DFy6NDmLaU0A7e2XoJz7PFYAEADFKbC"},
154 | // common bcrypt test vectors
155 | {"", "$2a$06$DCq7YPn5Rq63x1Lad4cll.TV4S6ytwfsfvkgY8jIucDrjc8deX1s."},
156 | {"", "$2a$08$HqWuK6/Ng6sg9gQzbLrgb.Tl.ZHfXLhvt/SgVyWhQqgqcZ7ZuUtye"},
157 | {"", "$2a$10$k1wbIrmNyFAPwPVPSVa/zecw2BCEnBwVS2GbrmgzxFUOqW9dk4TCW"},
158 | {"", "$2a$12$k42ZFHFWqBp3vWli.nIn8uYyIkbvYRvodzbfbK18SSsY.CsIQPlxO"},
159 | {"a", "$2a$06$m0CrhHm10qJ3lXRY.5zDGO3rS2KdeeWLuGmsfGlMfOxih58VYVfxe"},
160 | {"a", "$2a$08$cfcvVd2aQ8CMvoMpP2EBfeodLEkkFJ9umNEfPD18.hUF62qqlC/V."},
161 | {"a", "$2a$10$k87L/MF28Q673VKh8/cPi.SUl7MU/rWuSiIDDFayrKk/1tBsSQu4u"},
162 | {"a", "$2a$12$8NJH3LsPrANStV6XtBakCez0cKHXVxmvxIlcz785vxAIZrihHZpeS"},
163 | {"abc", "$2a$06$If6bvum7DFjUnE9p2uDeDu0YHzrHM6tf.iqN8.yx.jNN1ILEf7h0i"},
164 | {"abc", "$2a$08$Ro0CUfOqk6cXEKf3dyaM7OhSCvnwM9s4wIX9JeLapehKK5YdLxKcm"},
165 | {"abc", "$2a$10$WvvTPHKwdBJ3uk0Z37EMR.hLA2W6N9AEBhEgrAOljy2Ae5MtaSIUi"},
166 | {"abc", "$2a$12$EXRkfkdmXn2gzds2SSitu.MW9.gAVqa9eLS1//RYtYCmB1eLHg.9q"},
167 | {"abcdefghijklmnopqrstuvwxyz", "$2a$06$.rCVZVOThsIa97pEDOxvGuRRgzG64bvtJ0938xuqzv18d3ZpQhstC"},
168 | {"abcdefghijklmnopqrstuvwxyz", "$2a$08$aTsUwsyowQuzRrDqFflhgekJ8d9/7Z3GV3UcgvzQW3J5zMyrTvlz."},
169 | {"abcdefghijklmnopqrstuvwxyz", "$2a$10$fVH8e28OQRj9tqiDXs1e1uxpsjN0c7II7YPKXua2NAKYvM6iQk7dq"},
170 | {"abcdefghijklmnopqrstuvwxyz", "$2a$12$D4G5f18o7aMMfwasBL7GpuQWuP3pkrZrOAnqP.bmezbMng.QwJ/pG"},
171 | {"~!@#$%^&*() ~!@#$%^&*()PNBFRD", "$2a$06$fPIsBO8qRqkjj273rfaOI.HtSV9jLDpTbZn782DC6/t7qT67P6FfO"},
172 | {"~!@#$%^&*() ~!@#$%^&*()PNBFRD", "$2a$08$Eq2r4G/76Wv39MzSX262huzPz612MZiYHVUJe/OcOql2jo4.9UxTW"},
173 | {"~!@#$%^&*() ~!@#$%^&*()PNBFRD", "$2a$10$LgfYWkbzEvQ4JakH7rOvHe0y8pHKF9OaFgwUZ2q7W2FFZmZzJYlfS"},
174 | {"~!@#$%^&*() ~!@#$%^&*()PNBFRD", "$2a$12$WApznUOJfkEGSmYRfnkrPOr466oFDCaj4b6HY3EXGvfxm43seyhgC"},
175 | // unicode test vector
176 | {"ππππππππ", "$2a$10$.TtQJ4Jr6isd4Hp.mVfZeuh6Gws4rOQ/vdBczhDx.19NFK0Y84Dle"},
177 | // TODO: add test vectors for `$2b` and `$2x`
178 | }
179 | for i, tc := range data {
180 | t.Run(fmt.Sprintf("Vector%d", i), func(t *testing.T) {
181 | t.Parallel()
182 | password, secret := tc[0], tc[1]
183 | if !CheckSecret(password, secret) {
184 | t.Error("CheckSecret returned false, want true")
185 | }
186 | if CheckSecret(password+"x", secret) {
187 | t.Error("CheckSecret returned true for invalid password, want false")
188 | }
189 | secret = secret[0:len(secret)-1] + "x"
190 | if CheckSecret(password, secret) {
191 | t.Error("CheckSecret returned true for invalid secret, want false")
192 | }
193 | })
194 | }
195 | }
196 |
--------------------------------------------------------------------------------
/digest.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "context"
5 | "crypto/subtle"
6 | "fmt"
7 | "net/http"
8 | "net/url"
9 | "sort"
10 | "strconv"
11 | "strings"
12 | "sync"
13 | "time"
14 | )
15 |
16 | type digestClient struct {
17 | nc uint64
18 | lastSeen int64
19 | }
20 |
21 | // DigestAuth is an authenticator implementation for 'Digest' HTTP Authentication scheme (RFC 7616).
22 | //
23 | // Note: this implementation was written following now deprecated RFC
24 | // 2617, and supports only MD5 algorithm.
25 | //
26 | // TODO: Add support for SHA-256 and SHA-512/256 algorithms.
27 | type DigestAuth struct {
28 | Realm string
29 | Opaque string
30 | Secrets SecretProvider
31 | PlainTextSecrets bool
32 | IgnoreNonceCount bool
33 | // Headers used by authenticator. Set to ProxyHeaders to use with
34 | // proxy server. When nil, NormalHeaders are used.
35 | Headers *Headers
36 |
37 | /*
38 | Approximate size of Client's Cache. When actual number of
39 | tracked client nonces exceeds
40 | ClientCacheSize+ClientCacheTolerance, ClientCacheTolerance*2
41 | older entries are purged.
42 | */
43 | ClientCacheSize int
44 | ClientCacheTolerance int
45 |
46 | clients map[string]*digestClient
47 | mutex sync.RWMutex
48 | }
49 |
50 | // check that DigestAuth implements AuthenticatorInterface
51 | var _ = (AuthenticatorInterface)((*DigestAuth)(nil))
52 |
53 | type digestCacheEntry struct {
54 | nonce string
55 | lastSeen int64
56 | }
57 |
58 | type digestCache []digestCacheEntry
59 |
60 | func (c digestCache) Less(i, j int) bool {
61 | return c[i].lastSeen < c[j].lastSeen
62 | }
63 |
64 | func (c digestCache) Len() int {
65 | return len(c)
66 | }
67 |
68 | func (c digestCache) Swap(i, j int) {
69 | c[i], c[j] = c[j], c[i]
70 | }
71 |
72 | // Purge removes count oldest entries from DigestAuth.clients
73 | func (da *DigestAuth) Purge(count int) {
74 | da.mutex.Lock()
75 | da.purgeLocked(count)
76 | da.mutex.Unlock()
77 | }
78 |
79 | func (da *DigestAuth) purgeLocked(count int) {
80 | entries := make([]digestCacheEntry, 0, len(da.clients))
81 | for nonce, client := range da.clients {
82 | entries = append(entries, digestCacheEntry{nonce, client.lastSeen})
83 | }
84 | cache := digestCache(entries)
85 | sort.Sort(cache)
86 | for _, client := range cache[:count] {
87 | delete(da.clients, client.nonce)
88 | }
89 | }
90 |
91 | // RequireAuth is an http.HandlerFunc which initiates the
92 | // authentication process (or requires reauthentication).
93 | func (da *DigestAuth) RequireAuth(w http.ResponseWriter, r *http.Request) {
94 | da.mutex.RLock()
95 | clientsLen := len(da.clients)
96 | da.mutex.RUnlock()
97 |
98 | if clientsLen > da.ClientCacheSize+da.ClientCacheTolerance {
99 | da.Purge(da.ClientCacheTolerance * 2)
100 | }
101 | nonce := RandomKey()
102 |
103 | da.mutex.Lock()
104 | da.clients[nonce] = &digestClient{nc: 0, lastSeen: time.Now().UnixNano()}
105 | da.mutex.Unlock()
106 |
107 | da.mutex.RLock()
108 | w.Header().Set(contentType, da.Headers.V().UnauthContentType)
109 | w.Header().Set(da.Headers.V().Authenticate,
110 | fmt.Sprintf(`Digest realm="%s", nonce="%s", opaque="%s", algorithm=MD5, qop="auth"`,
111 | da.Realm, nonce, da.Opaque))
112 | w.WriteHeader(da.Headers.V().UnauthCode)
113 | w.Write([]byte(da.Headers.V().UnauthResponse))
114 | da.mutex.RUnlock()
115 | }
116 |
117 | // DigestAuthParams parses Authorization header from the
118 | // http.Request. Returns a map of auth parameters or nil if the header
119 | // is not a valid parsable Digest auth header.
120 | func DigestAuthParams(authorization string) map[string]string {
121 | s := strings.SplitN(authorization, " ", 2)
122 | if len(s) != 2 || s[0] != "Digest" {
123 | return nil
124 | }
125 |
126 | return ParsePairs(s[1])
127 | }
128 |
129 | // CheckAuth checks whether the request contains valid authentication
130 | // data. Returns a pair of username, authinfo, where username is the
131 | // name of the authenticated user or an empty string and authinfo is
132 | // the contents for the optional Authentication-Info response header.
133 | func (da *DigestAuth) CheckAuth(r *http.Request) (username string, authinfo *string) {
134 | da.mutex.RLock()
135 | defer da.mutex.RUnlock()
136 | username = ""
137 | authinfo = nil
138 | auth := DigestAuthParams(r.Header.Get(da.Headers.V().Authorization))
139 | if auth == nil {
140 | return "", nil
141 | }
142 | // RFC2617 Section 3.2.1 specifies that unset value of algorithm in
143 | // WWW-Authenticate Response header should be treated as
144 | // "MD5". According to section 3.2.2 the "algorithm" value in
145 | // subsequent Request Authorization header must be set to whatever
146 | // was supplied in the WWW-Authenticate Response header. This
147 | // implementation always returns an algorithm in WWW-Authenticate
148 | // header, however there seems to be broken clients in the wild
149 | // which do not set the algorithm. Assume the unset algorithm in
150 | // Authorization header to be equal to MD5.
151 | if _, ok := auth["algorithm"]; !ok {
152 | auth["algorithm"] = "MD5"
153 | }
154 | if da.Opaque != auth["opaque"] || auth["algorithm"] != "MD5" || auth["qop"] != "auth" {
155 | return "", nil
156 | }
157 |
158 | // Check if the requested URI matches auth header
159 | if r.RequestURI != auth["uri"] {
160 | // We allow auth["uri"] to be a full path prefix of request-uri
161 | // for some reason lost in history, which is probably wrong, but
162 | // used to be like that for quite some time
163 | // (https://tools.ietf.org/html/rfc2617#section-3.2.2 explicitly
164 | // says that auth["uri"] is the request-uri).
165 | //
166 | // TODO: make an option to allow only strict checking.
167 | switch u, err := url.Parse(auth["uri"]); {
168 | case err != nil:
169 | return "", nil
170 | case r.URL == nil:
171 | return "", nil
172 | case len(u.Path) > len(r.URL.Path):
173 | return "", nil
174 | case !strings.HasPrefix(r.URL.Path, u.Path):
175 | return "", nil
176 | }
177 | }
178 |
179 | HA1 := da.Secrets(auth["username"], da.Realm)
180 | if da.PlainTextSecrets {
181 | HA1 = H(auth["username"] + ":" + da.Realm + ":" + HA1)
182 | }
183 | HA2 := H(r.Method + ":" + auth["uri"])
184 | KD := H(strings.Join([]string{HA1, auth["nonce"], auth["nc"], auth["cnonce"], auth["qop"], HA2}, ":"))
185 |
186 | if subtle.ConstantTimeCompare([]byte(KD), []byte(auth["response"])) != 1 {
187 | return "", nil
188 | }
189 |
190 | // At this point crypto checks are completed and validated.
191 | // Now check if the session is valid.
192 |
193 | nc, err := strconv.ParseUint(auth["nc"], 16, 64)
194 | if err != nil {
195 | return "", nil
196 | }
197 |
198 | client, ok := da.clients[auth["nonce"]]
199 | if !ok {
200 | return "", nil
201 | }
202 | if client.nc != 0 && client.nc >= nc && !da.IgnoreNonceCount {
203 | return "", nil
204 | }
205 | client.nc = nc
206 | client.lastSeen = time.Now().UnixNano()
207 |
208 | respHA2 := H(":" + auth["uri"])
209 | rspauth := H(strings.Join([]string{HA1, auth["nonce"], auth["nc"], auth["cnonce"], auth["qop"], respHA2}, ":"))
210 |
211 | info := fmt.Sprintf(`qop="auth", rspauth="%s", cnonce="%s", nc="%s"`, rspauth, auth["cnonce"], auth["nc"])
212 | return auth["username"], &info
213 | }
214 |
215 | // Default values for ClientCacheSize and ClientCacheTolerance for DigestAuth
216 | const (
217 | DefaultClientCacheSize = 1000
218 | DefaultClientCacheTolerance = 100
219 | )
220 |
221 | // Wrap returns an http.HandlerFunc wraps AuthenticatedHandlerFunc
222 | // with this DigestAuth authentication checks. Once the request
223 | // contains valid credentials, it calls wrapped
224 | // AuthenticatedHandlerFunc.
225 | //
226 | // Deprecated: new code should use NewContext instead.
227 | func (da *DigestAuth) Wrap(wrapped AuthenticatedHandlerFunc) http.HandlerFunc {
228 | return func(w http.ResponseWriter, r *http.Request) {
229 | if username, authinfo := da.CheckAuth(r); username == "" {
230 | da.RequireAuth(w, r)
231 | } else {
232 | ar := &AuthenticatedRequest{Request: *r, Username: username}
233 | if authinfo != nil {
234 | w.Header().Set(da.Headers.V().AuthInfo, *authinfo)
235 | }
236 | wrapped(w, ar)
237 | }
238 | }
239 | }
240 |
241 | // JustCheck returns a new http.HandlerFunc, which requires
242 | // DigestAuth to successfully authenticate a user before calling
243 | // wrapped http.HandlerFunc.
244 | //
245 | // Authenticated Username is passed as an extra
246 | // X-Authenticated-Username header to the wrapped HandlerFunc.
247 | func (da *DigestAuth) JustCheck(wrapped http.HandlerFunc) http.HandlerFunc {
248 | return da.Wrap(func(w http.ResponseWriter, ar *AuthenticatedRequest) {
249 | ar.Header.Set(AuthUsernameHeader, ar.Username)
250 | wrapped(w, &ar.Request)
251 | })
252 | }
253 |
254 | // NewContext returns a context carrying authentication information for the request.
255 | func (da *DigestAuth) NewContext(ctx context.Context, r *http.Request) context.Context {
256 | username, authinfo := da.CheckAuth(r)
257 | da.mutex.Lock()
258 | defer da.mutex.Unlock()
259 | info := &Info{Username: username, ResponseHeaders: make(http.Header)}
260 | if username != "" {
261 | info.Authenticated = true
262 | info.ResponseHeaders.Set(da.Headers.V().AuthInfo, *authinfo)
263 | } else {
264 | // return back digest WWW-Authenticate header
265 | if len(da.clients) > da.ClientCacheSize+da.ClientCacheTolerance {
266 | da.purgeLocked(da.ClientCacheTolerance * 2)
267 | }
268 | nonce := RandomKey()
269 | da.clients[nonce] = &digestClient{nc: 0, lastSeen: time.Now().UnixNano()}
270 | info.ResponseHeaders.Set(da.Headers.V().Authenticate,
271 | fmt.Sprintf(`Digest realm="%s", nonce="%s", opaque="%s", algorithm=MD5, qop="auth"`,
272 | da.Realm, nonce, da.Opaque))
273 | }
274 | return context.WithValue(ctx, infoKey, info)
275 | }
276 |
277 | // NewDigestAuthenticator generates a new DigestAuth object
278 | func NewDigestAuthenticator(realm string, secrets SecretProvider) *DigestAuth {
279 | da := &DigestAuth{
280 | Opaque: RandomKey(),
281 | Realm: realm,
282 | Secrets: secrets,
283 | PlainTextSecrets: false,
284 | ClientCacheSize: DefaultClientCacheSize,
285 | ClientCacheTolerance: DefaultClientCacheTolerance,
286 | clients: map[string]*digestClient{}}
287 | return da
288 | }
289 |
--------------------------------------------------------------------------------
/digest_test.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "context"
5 | "net/http"
6 | "net/url"
7 | "sync"
8 | "testing"
9 | "time"
10 | )
11 |
12 | func TestAuthDigest(t *testing.T) {
13 | t.Parallel()
14 | secrets := HtdigestFileProvider("test.htdigest")
15 | da := &DigestAuth{Opaque: "U7H+ier3Ae8Skd/g",
16 | Realm: "example.com",
17 | Secrets: secrets,
18 | clients: map[string]*digestClient{}}
19 | r := &http.Request{}
20 | r.Method = "GET"
21 | if u, _ := da.CheckAuth(r); u != "" {
22 | t.Fatal("non-empty auth for empty request header")
23 | }
24 | r.Header = http.Header(make(map[string][]string))
25 | r.Header.Set("Authorization", "Digest blabla")
26 | if u, _ := da.CheckAuth(r); u != "" {
27 | t.Fatal("non-empty auth for bad request header")
28 | }
29 | r.Header.Set("Authorization", `Digest username="test", realm="example.com", nonce="Vb9BP/h81n3GpTTB", uri="/t2", cnonce="NjE4MTM2", nc=00000001, qop="auth", response="ffc357c4eba74773c8687e0bc724c9a3", opaque="U7H+ier3Ae8Skd/g", algorithm=MD5`)
30 | if u, _ := da.CheckAuth(r); u != "" {
31 | t.Fatal("non-empty auth for unknown client")
32 | }
33 |
34 | r.URL, _ = url.Parse("/t2")
35 | da.clients["Vb9BP/h81n3GpTTB"] = &digestClient{nc: 0, lastSeen: time.Now().UnixNano()}
36 | if u, _ := da.CheckAuth(r); u != "test" {
37 | t.Fatal("empty auth for legitimate client")
38 | }
39 |
40 | // our nc is now 0, client nc is 1
41 | if u, _ := da.CheckAuth(r); u != "" {
42 | t.Fatal("non-empty auth for outdated nc")
43 | }
44 |
45 | // try again with nc checking off
46 | da.IgnoreNonceCount = true
47 | if u, _ := da.CheckAuth(r); u != "test" {
48 | t.Fatal("empty auth for outdated nc even though nc checking is off")
49 | }
50 | da.IgnoreNonceCount = false
51 |
52 | r.URL, _ = url.Parse("/")
53 | da.clients["Vb9BP/h81n3GpTTB"] = &digestClient{nc: 0, lastSeen: time.Now().UnixNano()}
54 | if u, _ := da.CheckAuth(r); u != "" {
55 | t.Fatal("non-empty auth for bad request path")
56 | }
57 |
58 | r.URL, _ = url.Parse("/t3")
59 | da.clients["Vb9BP/h81n3GpTTB"] = &digestClient{nc: 0, lastSeen: time.Now().UnixNano()}
60 | if u, _ := da.CheckAuth(r); u != "" {
61 | t.Fatal("non-empty auth for bad request path")
62 | }
63 |
64 | da.clients["+RbVXSbIoa1SaJk1"] = &digestClient{nc: 0, lastSeen: time.Now().UnixNano()}
65 | r.Header.Set("Authorization", `Digest username="test", realm="example.com", nonce="+RbVXSbIoa1SaJk1", uri="/", cnonce="NjE4NDkw", nc=00000001, qop="auth", response="c08918024d7faaabd5424654c4e3ad1c", opaque="U7H+ier3Ae8Skd/g", algorithm=MD5`)
66 | if u, _ := da.CheckAuth(r); u != "test" {
67 | t.Fatal("empty auth for valid request in subpath")
68 | }
69 | }
70 |
71 | func TestDigestAuthParams(t *testing.T) {
72 | t.Parallel()
73 | const authorization = `Digest username="test", realm="", nonce="FRPnGdb8lvM1UHhi", uri="/css?family=Source+Sans+Pro:400,700,400italic,700italic|Source+Code+Pro", algorithm=MD5, response="fdcdd78e5b306ffed343d0ec3967f2e5", opaque="lEgVjogmIar2fg/t", qop=auth, nc=00000001, cnonce="e76b05db27a3b323"`
74 |
75 | params := DigestAuthParams(authorization)
76 | want := "/css?family=Source+Sans+Pro:400,700,400italic,700italic|Source+Code+Pro"
77 | if params["uri"] != want {
78 | t.Fatalf("failed to parse uri with embedded commas, got %q want %q", params["uri"], want)
79 | }
80 | }
81 |
82 | func TestNewContextNoDeadlock(t *testing.T) {
83 | t.Parallel()
84 | const (
85 | realm = "example.com"
86 | user = "user"
87 | )
88 | secrets := func(u, r string) string {
89 | if u == user && r == realm {
90 | return "aa78524fceb0e50fd8ca96dd818b8cf9"
91 | }
92 | return ""
93 | }
94 | da := NewDigestAuthenticator(realm, secrets)
95 | da.ClientCacheSize = 10
96 | da.ClientCacheTolerance = 1
97 | var wg sync.WaitGroup
98 | for i := 0; i < 100; i++ {
99 | ctx := context.Background()
100 | req, err := http.NewRequest("GET", "/", nil)
101 | if err != nil {
102 | t.Fatalf("Failed to create http.Request: %v", err)
103 | }
104 | wg.Add(1)
105 | go func() {
106 | defer wg.Done()
107 | done := make(chan struct{})
108 | go func() {
109 | da.NewContext(ctx, req)
110 | close(done)
111 | }()
112 | select {
113 | case <-done:
114 | return
115 | case <-time.After(time.Second):
116 | t.Error("deadlock detected")
117 | }
118 | }()
119 | }
120 | wg.Wait()
121 | }
122 |
--------------------------------------------------------------------------------
/examples/basic.go:
--------------------------------------------------------------------------------
1 | // +build ignore
2 |
3 | /*
4 | Example application using Basic auth
5 |
6 | Build with:
7 |
8 | go build basic.go
9 | */
10 |
11 | package main
12 |
13 | import (
14 | "fmt"
15 | "net/http"
16 |
17 | auth ".."
18 | )
19 |
20 | func secret(user, realm string) string {
21 | if user == "john" {
22 | // password is "hello"
23 | return "$1$dlPL2MqE$oQmn16q49SqdmhenQuNgs1"
24 | }
25 | return ""
26 | }
27 |
28 | func handle(w http.ResponseWriter, r *auth.AuthenticatedRequest) {
29 | fmt.Fprintf(w, "Hello, %s!
", r.Username)
30 | }
31 |
32 | func main() {
33 | authenticator := auth.NewBasicAuthenticator("example.com", secret)
34 | http.HandleFunc("/", authenticator.Wrap(handle))
35 | http.ListenAndServe(":8080", nil)
36 | }
37 |
--------------------------------------------------------------------------------
/examples/context.go:
--------------------------------------------------------------------------------
1 | // +build ignore
2 |
3 | /*
4 | Example application using NewContext/FromContext
5 |
6 | Build with:
7 |
8 | go build context.go
9 | */
10 |
11 | package main
12 |
13 | import (
14 | "context"
15 | "fmt"
16 | "net/http"
17 |
18 | auth ".."
19 | )
20 |
21 | func secret(user, realm string) string {
22 | if user == "john" {
23 | // password is "hello"
24 | return "b98e16cbc3d01734b264adba7baa3bf9"
25 | }
26 | return ""
27 | }
28 |
29 | type contextHandler interface {
30 | ServeHTTP(ctx context.Context, w http.ResponseWriter, r *http.Request)
31 | }
32 |
33 | type contextHandlerFunc func(ctx context.Context, w http.ResponseWriter, r *http.Request)
34 |
35 | func (f contextHandlerFunc) ServeHTTP(ctx context.Context, w http.ResponseWriter, r *http.Request) {
36 | f(ctx, w, r)
37 | }
38 |
39 | func handle(ctx context.Context, w http.ResponseWriter, r *http.Request) {
40 | authInfo := auth.FromContext(ctx)
41 | authInfo.UpdateHeaders(w.Header())
42 | if authInfo == nil || !authInfo.Authenticated {
43 | http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
44 | return
45 | }
46 | fmt.Fprintf(w, "Hello, %s!
", authInfo.Username)
47 | }
48 |
49 | func authenticatedHandler(a auth.AuthenticatorInterface, h contextHandler) http.Handler {
50 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
51 | ctx := a.NewContext(context.Background(), r)
52 | h.ServeHTTP(ctx, w, r)
53 | })
54 | }
55 |
56 | func main() {
57 | authenticator := auth.NewDigestAuthenticator("example.com", secret)
58 | http.Handle("/", authenticatedHandler(authenticator, contextHandlerFunc(handle)))
59 | http.ListenAndServe(":8080", nil)
60 | }
61 |
--------------------------------------------------------------------------------
/examples/digest.go:
--------------------------------------------------------------------------------
1 | // +build ignore
2 |
3 | /*
4 | Example application using Digest auth
5 |
6 | Build with:
7 |
8 | go build digest.go
9 | */
10 |
11 | package main
12 |
13 | import (
14 | "fmt"
15 | "net/http"
16 |
17 | auth ".."
18 | )
19 |
20 | func secret(user, realm string) string {
21 | if user == "john" {
22 | // password is "hello"
23 | return "b98e16cbc3d01734b264adba7baa3bf9"
24 | }
25 | return ""
26 | }
27 |
28 | func handle(w http.ResponseWriter, r *auth.AuthenticatedRequest) {
29 | fmt.Fprintf(w, "Hello, %s!
", r.Username)
30 | }
31 |
32 | func main() {
33 | authenticator := auth.NewDigestAuthenticator("example.com", secret)
34 | http.HandleFunc("/", authenticator.Wrap(handle))
35 | http.ListenAndServe(":8080", nil)
36 | }
37 |
--------------------------------------------------------------------------------
/examples/wrapped.go:
--------------------------------------------------------------------------------
1 | // +build ignore
2 |
3 | /*
4 | Example demonstrating how to wrap an application which is unaware of
5 | authenticated requests with a "pass-through" authentication
6 |
7 | Build with:
8 |
9 | go build wrapped.go
10 | */
11 |
12 | package main
13 |
14 | import (
15 | "fmt"
16 | "net/http"
17 |
18 | auth ".."
19 | )
20 |
21 | func secret(user, realm string) string {
22 | if user == "john" {
23 | // password is "hello"
24 | return "$1$dlPL2MqE$oQmn16q49SqdmhenQuNgs1"
25 | }
26 | return ""
27 | }
28 |
29 | func regularHandler(w http.ResponseWriter, r *http.Request) {
30 | fmt.Fprintf(w, "This application is unaware of authentication
")
31 | }
32 |
33 | func main() {
34 | authenticator := auth.NewBasicAuthenticator("example.com", secret)
35 | http.HandleFunc("/", auth.JustCheck(authenticator, regularHandler))
36 | http.ListenAndServe(":8080", nil)
37 | }
38 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/abbot/go-http-auth
2 |
3 | go 1.14
4 |
5 | require golang.org/x/crypto v0.1.0
6 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
2 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
3 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
4 | golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU=
5 | golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
6 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
7 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
8 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
9 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
10 | golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
11 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
12 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
13 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
14 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
15 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
16 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
17 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
18 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
19 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
20 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
21 | golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
22 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
23 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
24 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
25 | golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
26 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
27 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
28 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
29 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
30 |
--------------------------------------------------------------------------------
/md5crypt.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import "crypto/md5"
4 |
5 | const itoa64 = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
6 |
7 | var md5CryptSwaps = [16]int{12, 6, 0, 13, 7, 1, 14, 8, 2, 15, 9, 3, 5, 10, 4, 11}
8 |
9 | // MD5Crypt is the MD5 password crypt implementation.
10 | func MD5Crypt(password, salt, magic []byte) []byte {
11 | d := md5.New()
12 |
13 | d.Write(password)
14 | d.Write(magic)
15 | d.Write(salt)
16 |
17 | d2 := md5.New()
18 | d2.Write(password)
19 | d2.Write(salt)
20 | d2.Write(password)
21 |
22 | for i, mixin := 0, d2.Sum(nil); i < len(password); i++ {
23 | d.Write([]byte{mixin[i%16]})
24 | }
25 |
26 | for i := len(password); i != 0; i >>= 1 {
27 | if i&1 == 0 {
28 | d.Write([]byte{password[0]})
29 | } else {
30 | d.Write([]byte{0})
31 | }
32 | }
33 |
34 | final := d.Sum(nil)
35 |
36 | for i := 0; i < 1000; i++ {
37 | d2 := md5.New()
38 | if i&1 == 0 {
39 | d2.Write(final)
40 | } else {
41 | d2.Write(password)
42 | }
43 |
44 | if i%3 != 0 {
45 | d2.Write(salt)
46 | }
47 |
48 | if i%7 != 0 {
49 | d2.Write(password)
50 | }
51 |
52 | if i&1 == 0 {
53 | d2.Write(password)
54 | } else {
55 | d2.Write(final)
56 | }
57 | final = d2.Sum(nil)
58 | }
59 |
60 | result := make([]byte, 0, 22)
61 | v := uint(0)
62 | bits := uint(0)
63 | for _, i := range md5CryptSwaps {
64 | v |= (uint(final[i]) << bits)
65 | for bits = bits + 8; bits > 6; bits -= 6 {
66 | result = append(result, itoa64[v&0x3f])
67 | v >>= 6
68 | }
69 | }
70 | result = append(result, itoa64[v&0x3f])
71 |
72 | return append(append(append(magic, salt...), '$'), result...)
73 | }
74 |
--------------------------------------------------------------------------------
/md5crypt_test.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "strings"
5 | "testing"
6 | )
7 |
8 | type md5entry struct {
9 | Magic, Salt, Hash []byte
10 | }
11 |
12 | func newEntry(e string) *md5entry {
13 | parts := strings.SplitN(e, "$", 4)
14 | if len(parts) != 4 {
15 | return nil
16 | }
17 | return &md5entry{
18 | Magic: []byte("$" + parts[1] + "$"),
19 | Salt: []byte(parts[2]),
20 | Hash: []byte(parts[3]),
21 | }
22 | }
23 |
24 | func Test_MD5Crypt(t *testing.T) {
25 | t.Parallel()
26 | testCases := [][]string{
27 | {"apache", "$apr1$J.w5a/..$IW9y6DR0oO/ADuhlMF5/X1"},
28 | {"pass", "$1$YeNsbWdH$wvOF8JdqsoiLix754LTW90"},
29 | {"topsecret", "$apr1$JI4wh3am$AmhephVqLTUyAVpFQeHZC0"},
30 | }
31 | for _, tc := range testCases {
32 | e := newEntry(tc[1])
33 | result := MD5Crypt([]byte(tc[0]), e.Salt, e.Magic)
34 | if string(result) != tc[1] {
35 | t.Fatalf("MD5Crypt returned '%s' instead of '%s'", string(result), tc[1])
36 | }
37 | t.Logf("MD5Crypt: '%s' (%s%s$) -> %s", tc[0], e.Magic, e.Salt, result)
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/misc.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "bytes"
5 | "crypto/md5"
6 | "crypto/rand"
7 | "encoding/base64"
8 | "fmt"
9 | "net/http"
10 | "strings"
11 | )
12 |
13 | // RandomKey returns a random 16-byte base64 alphabet string
14 | func RandomKey() string {
15 | k := make([]byte, 12)
16 | for bytes := 0; bytes < len(k); {
17 | n, err := rand.Read(k[bytes:])
18 | if err != nil {
19 | panic("rand.Read() failed")
20 | }
21 | bytes += n
22 | }
23 | return base64.StdEncoding.EncodeToString(k)
24 | }
25 |
26 | // H function for MD5 algorithm (returns a lower-case hex MD5 digest)
27 | func H(data string) string {
28 | digest := md5.New()
29 | digest.Write([]byte(data))
30 | return fmt.Sprintf("%x", digest.Sum(nil))
31 | }
32 |
33 | // ParseList parses a comma-separated list of values as described by
34 | // RFC 2068 and returns list elements.
35 | //
36 | // Lifted from https://code.google.com/p/gorilla/source/browse/http/parser/parser.go
37 | // which was ported from urllib2.parse_http_list, from the Python
38 | // standard library.
39 | func ParseList(value string) []string {
40 | var list []string
41 | var escape, quote bool
42 | b := new(bytes.Buffer)
43 | for _, r := range value {
44 | switch {
45 | case escape:
46 | b.WriteRune(r)
47 | escape = false
48 | case quote:
49 | if r == '\\' {
50 | escape = true
51 | } else {
52 | if r == '"' {
53 | quote = false
54 | }
55 | b.WriteRune(r)
56 | }
57 | case r == ',':
58 | list = append(list, strings.TrimSpace(b.String()))
59 | b.Reset()
60 | case r == '"':
61 | quote = true
62 | b.WriteRune(r)
63 | default:
64 | b.WriteRune(r)
65 | }
66 | }
67 | // Append last part.
68 | if s := b.String(); s != "" {
69 | list = append(list, strings.TrimSpace(s))
70 | }
71 | return list
72 | }
73 |
74 | // ParsePairs extracts key/value pairs from a comma-separated list of
75 | // values as described by RFC 2068 and returns a map[key]value. The
76 | // resulting values are unquoted. If a list element doesn't contain a
77 | // "=", the key is the element itself and the value is an empty
78 | // string.
79 | //
80 | // Lifted from https://code.google.com/p/gorilla/source/browse/http/parser/parser.go
81 | func ParsePairs(value string) map[string]string {
82 | m := make(map[string]string)
83 | for _, pair := range ParseList(strings.TrimSpace(value)) {
84 | switch i := strings.Index(pair, "="); {
85 | case i < 0:
86 | // No '=' in pair, treat whole string as a 'key'.
87 | m[pair] = ""
88 | case i == len(pair)-1:
89 | // Malformed pair ('key=' with no value), keep key with empty value.
90 | m[pair[:i]] = ""
91 | default:
92 | v := pair[i+1:]
93 | if v[0] == '"' && v[len(v)-1] == '"' {
94 | // Unquote it.
95 | v = v[1 : len(v)-1]
96 | }
97 | m[pair[:i]] = v
98 | }
99 | }
100 | return m
101 | }
102 |
103 | // Headers contains header and error codes used by authenticator.
104 | type Headers struct {
105 | Authenticate string // WWW-Authenticate
106 | Authorization string // Authorization
107 | AuthInfo string // Authentication-Info
108 | UnauthCode int // 401
109 | UnauthContentType string // text/plain
110 | UnauthResponse string // Unauthorized.
111 | }
112 |
113 | // V returns NormalHeaders when h is nil, or h otherwise. Allows to
114 | // use uninitialized *Headers values in structs.
115 | func (h *Headers) V() *Headers {
116 | if h == nil {
117 | return NormalHeaders
118 | }
119 | return h
120 | }
121 |
122 | var (
123 | // NormalHeaders are the regular Headers used by an HTTP Server for
124 | // request authentication.
125 | NormalHeaders = &Headers{
126 | Authenticate: "WWW-Authenticate",
127 | Authorization: "Authorization",
128 | AuthInfo: "Authentication-Info",
129 | UnauthCode: http.StatusUnauthorized,
130 | UnauthContentType: "text/plain",
131 | UnauthResponse: fmt.Sprintf("%d %s\n", http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized)),
132 | }
133 |
134 | // ProxyHeaders are Headers used by an HTTP Proxy server for proxy
135 | // access authentication.
136 | ProxyHeaders = &Headers{
137 | Authenticate: "Proxy-Authenticate",
138 | Authorization: "Proxy-Authorization",
139 | AuthInfo: "Proxy-Authentication-Info",
140 | UnauthCode: http.StatusProxyAuthRequired,
141 | UnauthContentType: "text/plain",
142 | UnauthResponse: fmt.Sprintf("%d %s\n", http.StatusProxyAuthRequired, http.StatusText(http.StatusProxyAuthRequired)),
143 | }
144 | )
145 |
146 | const contentType = "Content-Type"
147 |
--------------------------------------------------------------------------------
/misc_test.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "reflect"
5 | "testing"
6 | )
7 |
8 | func TestH(t *testing.T) {
9 | t.Parallel()
10 | const hello = "Hello, world!"
11 | const helloMD5 = "6cd3556deb0da54bca060b4c39479839"
12 | h := H(hello)
13 | if h != helloMD5 {
14 | t.Fatal("Incorrect digest for test string:", h, "instead of", helloMD5)
15 | }
16 | }
17 |
18 | func TestParsePairs(t *testing.T) {
19 | t.Parallel()
20 | const header = `username="\test", realm="a \"quoted\" string", nonce="FRPnGdb8lvM1UHhi", uri="/css?family=Source+Sans+Pro:400,700,400italic,700italic|Source+Code+Pro", algorithm=MD5, response="fdcdd78e5b306ffed343d0ec3967f2e5", opaque="lEgVjogmIar2fg/t", qop=auth, nc=00000001, cnonce="e76b05db27a3b323", empty1=, empty2=""`
21 |
22 | want := map[string]string{
23 | "username": "test",
24 | "realm": `a "quoted" string`,
25 | "nonce": "FRPnGdb8lvM1UHhi",
26 | "uri": "/css?family=Source+Sans+Pro:400,700,400italic,700italic|Source+Code+Pro",
27 | "algorithm": "MD5",
28 | "response": "fdcdd78e5b306ffed343d0ec3967f2e5",
29 | "opaque": "lEgVjogmIar2fg/t",
30 | "qop": "auth",
31 | "nc": "00000001",
32 | "cnonce": "e76b05db27a3b323",
33 | "empty1": "",
34 | "empty2": "",
35 | }
36 | got := ParsePairs(header)
37 |
38 | if !reflect.DeepEqual(got, want) {
39 | t.Fatalf("failed to correctly parse pairs, got %v, want %v", got, want)
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/test.htdigest:
--------------------------------------------------------------------------------
1 | test:example.com:aa78524fceb0e50fd8ca96dd818b8cf9
2 |
--------------------------------------------------------------------------------
/test.htpasswd:
--------------------------------------------------------------------------------
1 | test:{SHA}qvTGHdzF6KLavt4PO0gs2a6pQ00=
2 | test2:$apr1$a0j62R97$mYqFkloXH0/UOaUnAiV2b0
3 | test16:$apr1$JI4wh3am$AmhephVqLTUyAVpFQeHZC0
4 | test3:$2y$05$ih3C91zUBSTFcAh2mQnZYuob0UOZVEf16wl/ukgjDhjvj.xgM1WwS
5 |
--------------------------------------------------------------------------------
/users.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "encoding/csv"
5 | "os"
6 | "sync"
7 | )
8 |
9 | // SecretProvider is used by authenticators. Takes user name and realm
10 | // as an argument, returns secret required for authentication (HA1 for
11 | // digest authentication, properly encrypted password for basic).
12 | //
13 | // Returning an empty string means failing the authentication.
14 | type SecretProvider func(user, realm string) string
15 |
16 | // File handles automatic file reloading on changes.
17 | type File struct {
18 | Path string
19 | Info os.FileInfo
20 | /* must be set in inherited types during initialization */
21 | Reload func()
22 | mu sync.Mutex
23 | }
24 |
25 | // ReloadIfNeeded checks file Stat and calls Reload() if any changes
26 | // were detected. File mutex is Locked for the duration of Reload()
27 | // call.
28 | //
29 | // This function will panic() if Stat fails.
30 | func (f *File) ReloadIfNeeded() {
31 | info, err := os.Stat(f.Path)
32 | if err != nil {
33 | panic(err)
34 | }
35 | f.mu.Lock()
36 | defer f.mu.Unlock()
37 | if f.Info == nil || f.Info.ModTime() != info.ModTime() {
38 | f.Info = info
39 | f.Reload()
40 | }
41 | }
42 |
43 | // HtdigestFile is a File holding htdigest authentication data.
44 | type HtdigestFile struct {
45 | // File is used for automatic reloading of the authentication data.
46 | File
47 | // Users is a map of realms to users to HA1 digests.
48 | Users map[string]map[string]string
49 | mu sync.RWMutex
50 | }
51 |
52 | func reloadHTDigest(hf *HtdigestFile) {
53 | r, err := os.Open(hf.Path)
54 | if err != nil {
55 | panic(err)
56 | }
57 | reader := csv.NewReader(r)
58 | reader.Comma = ':'
59 | reader.Comment = '#'
60 | reader.TrimLeadingSpace = true
61 |
62 | records, err := reader.ReadAll()
63 | if err != nil {
64 | panic(err)
65 | }
66 |
67 | hf.mu.Lock()
68 | defer hf.mu.Unlock()
69 | hf.Users = make(map[string]map[string]string)
70 | for _, record := range records {
71 | _, exists := hf.Users[record[1]]
72 | if !exists {
73 | hf.Users[record[1]] = make(map[string]string)
74 | }
75 | hf.Users[record[1]][record[0]] = record[2]
76 | }
77 | }
78 |
79 | // HtdigestFileProvider is a SecretProvider implementation based on
80 | // htdigest-formated files. It will automatically reload htdigest file
81 | // on changes. It panics on syntax errors in htdigest files.
82 | func HtdigestFileProvider(filename string) SecretProvider {
83 | hf := &HtdigestFile{File: File{Path: filename}}
84 | hf.Reload = func() { reloadHTDigest(hf) }
85 | return func(user, realm string) string {
86 | hf.ReloadIfNeeded()
87 | hf.mu.RLock()
88 | defer hf.mu.RUnlock()
89 | _, exists := hf.Users[realm]
90 | if !exists {
91 | return ""
92 | }
93 | digest, exists := hf.Users[realm][user]
94 | if !exists {
95 | return ""
96 | }
97 | return digest
98 | }
99 | }
100 |
101 | // HtpasswdFile is a File holding basic authentication data.
102 | type HtpasswdFile struct {
103 | // File is used for automatic reloading of the authentication data.
104 | File
105 | // Users is a map of users to their secrets (salted encrypted
106 | // passwords).
107 | Users map[string]string
108 | mu sync.RWMutex
109 | }
110 |
111 | func reloadHTPasswd(h *HtpasswdFile) {
112 | r, err := os.Open(h.Path)
113 | if err != nil {
114 | panic(err)
115 | }
116 | reader := csv.NewReader(r)
117 | reader.Comma = ':'
118 | reader.Comment = '#'
119 | reader.TrimLeadingSpace = true
120 |
121 | records, err := reader.ReadAll()
122 | if err != nil {
123 | panic(err)
124 | }
125 |
126 | h.mu.Lock()
127 | defer h.mu.Unlock()
128 | h.Users = make(map[string]string)
129 | for _, record := range records {
130 | h.Users[record[0]] = record[1]
131 | }
132 | }
133 |
134 | // HtpasswdFileProvider is a SecretProvider implementation based on
135 | // htpasswd-formated files. It will automatically reload htpasswd file
136 | // on changes. It panics on syntax errors in htpasswd files. Realm
137 | // argument of the SecretProvider is ignored.
138 | func HtpasswdFileProvider(filename string) SecretProvider {
139 | h := &HtpasswdFile{File: File{Path: filename}}
140 | h.Reload = func() { reloadHTPasswd(h) }
141 | return func(user, realm string) string {
142 | h.ReloadIfNeeded()
143 | h.mu.RLock()
144 | password, exists := h.Users[user]
145 | h.mu.RUnlock()
146 | if !exists {
147 | return ""
148 | }
149 | return password
150 | }
151 | }
152 |
--------------------------------------------------------------------------------
/users_test.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "os"
5 | "testing"
6 | "time"
7 | )
8 |
9 | func TestHtdigestFile(t *testing.T) {
10 | t.Parallel()
11 | secrets := HtdigestFileProvider("test.htdigest")
12 | digest := secrets("test", "example.com")
13 | if digest != "aa78524fceb0e50fd8ca96dd818b8cf9" {
14 | t.Fatal("Incorrect digest for test user:", digest)
15 | }
16 | digest = secrets("test", "example1.com")
17 | if digest != "" {
18 | t.Fatal("Got digest for user in non-existant realm:", digest)
19 | }
20 | digest = secrets("test1", "example.com")
21 | if digest != "" {
22 | t.Fatal("Got digest for non-existant user:", digest)
23 | }
24 | }
25 |
26 | func TestHtpasswdFile(t *testing.T) {
27 | t.Parallel()
28 | secrets := HtpasswdFileProvider("test.htpasswd")
29 | passwd := secrets("test", "blah")
30 | if passwd != "{SHA}qvTGHdzF6KLavt4PO0gs2a6pQ00=" {
31 | t.Fatal("Incorrect passwd for test user:", passwd)
32 | }
33 | passwd = secrets("nosuchuser", "blah")
34 | if passwd != "" {
35 | t.Fatal("Got passwd for non-existant user:", passwd)
36 | }
37 | }
38 |
39 | // TestConcurrent verifies potential race condition in users reading logic
40 | func TestConcurrent(t *testing.T) {
41 | t.Parallel()
42 | secrets := HtpasswdFileProvider("test.htpasswd")
43 | os.Chtimes("test.htpasswd", time.Now(), time.Now())
44 | go func() {
45 | secrets("test", "blah")
46 | }()
47 | secrets("test", "blah")
48 | }
49 |
--------------------------------------------------------------------------------