mirror of
https://github.com/caddyserver/caddy.git
synced 2024-11-29 12:16:16 +08:00
54acb9b2de
If a site owner protects a path with basicauth, no need to use the Authorization header elsewhere upstream, especially since it contains credentials. If this breaks anyone, it means they're double-dipping. It's usually good practice to clear out credentials as soon as they're not needed anymore. (Note that we only clear credentials after they're used, they stay for any other reason.)
163 lines
4.3 KiB
Go
163 lines
4.3 KiB
Go
// Package basicauth implements HTTP Basic Authentication for Caddy.
|
|
//
|
|
// This is useful for simple protections on a website, like requiring
|
|
// a password to access an admin interface. This package assumes a
|
|
// fairly small threat model.
|
|
package basicauth
|
|
|
|
import (
|
|
"bufio"
|
|
"crypto/sha1"
|
|
"crypto/subtle"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/jimstudt/http-authentication/basic"
|
|
"github.com/mholt/caddy/caddyhttp/httpserver"
|
|
)
|
|
|
|
// BasicAuth is middleware to protect resources with a username and password.
|
|
// Note that HTTP Basic Authentication is not secure by itself and should
|
|
// not be used to protect important assets without HTTPS. Even then, the
|
|
// security of HTTP Basic Auth is disputed. Use discretion when deciding
|
|
// what to protect with BasicAuth.
|
|
type BasicAuth struct {
|
|
Next httpserver.Handler
|
|
SiteRoot string
|
|
Rules []Rule
|
|
}
|
|
|
|
// ServeHTTP implements the httpserver.Handler interface.
|
|
func (a BasicAuth) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) {
|
|
var protected, isAuthenticated bool
|
|
|
|
for _, rule := range a.Rules {
|
|
for _, res := range rule.Resources {
|
|
if !httpserver.Path(r.URL.Path).Matches(res) {
|
|
continue
|
|
}
|
|
|
|
// path matches; this endpoint is protected
|
|
protected = true
|
|
|
|
// parse auth header
|
|
username, password, ok := r.BasicAuth()
|
|
|
|
// check credentials
|
|
if !ok ||
|
|
username != rule.Username ||
|
|
!rule.Password(password) {
|
|
continue
|
|
}
|
|
|
|
// by this point, authentication was successful
|
|
isAuthenticated = true
|
|
|
|
// remove credentials from request to avoid leaking upstream
|
|
r.Header.Del("Authorization")
|
|
}
|
|
}
|
|
|
|
if protected && !isAuthenticated {
|
|
// browsers show a message that says something like:
|
|
// "The website says: <realm>"
|
|
// which is kinda dumb, but whatever.
|
|
w.Header().Set("WWW-Authenticate", "Basic realm=\"Restricted\"")
|
|
return http.StatusUnauthorized, nil
|
|
}
|
|
|
|
// Pass-through when no paths match
|
|
return a.Next.ServeHTTP(w, r)
|
|
}
|
|
|
|
// Rule represents a BasicAuth rule. A username and password
|
|
// combination protect the associated resources, which are
|
|
// file or directory paths.
|
|
type Rule struct {
|
|
Username string
|
|
Password func(string) bool
|
|
Resources []string
|
|
}
|
|
|
|
// PasswordMatcher determines whether a password matches a rule.
|
|
type PasswordMatcher func(pw string) bool
|
|
|
|
var (
|
|
htpasswords map[string]map[string]PasswordMatcher
|
|
htpasswordsMu sync.Mutex
|
|
)
|
|
|
|
// GetHtpasswdMatcher matches password rules.
|
|
func GetHtpasswdMatcher(filename, username, siteRoot string) (PasswordMatcher, error) {
|
|
filename = filepath.Join(siteRoot, filename)
|
|
htpasswordsMu.Lock()
|
|
if htpasswords == nil {
|
|
htpasswords = make(map[string]map[string]PasswordMatcher)
|
|
}
|
|
pm := htpasswords[filename]
|
|
if pm == nil {
|
|
fh, err := os.Open(filename)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open %q: %v", filename, err)
|
|
}
|
|
defer fh.Close()
|
|
pm = make(map[string]PasswordMatcher)
|
|
if err = parseHtpasswd(pm, fh); err != nil {
|
|
return nil, fmt.Errorf("parsing htpasswd %q: %v", fh.Name(), err)
|
|
}
|
|
htpasswords[filename] = pm
|
|
}
|
|
htpasswordsMu.Unlock()
|
|
if pm[username] == nil {
|
|
return nil, fmt.Errorf("username %q not found in %q", username, filename)
|
|
}
|
|
return pm[username], nil
|
|
}
|
|
|
|
func parseHtpasswd(pm map[string]PasswordMatcher, r io.Reader) error {
|
|
scanner := bufio.NewScanner(r)
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" || strings.IndexByte(line, '#') == 0 {
|
|
continue
|
|
}
|
|
i := strings.IndexByte(line, ':')
|
|
if i <= 0 {
|
|
return fmt.Errorf("malformed line, no color: %q", line)
|
|
}
|
|
user, encoded := line[:i], line[i+1:]
|
|
for _, p := range basic.DefaultSystems {
|
|
matcher, err := p(encoded)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if matcher != nil {
|
|
pm[user] = matcher.MatchesPassword
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return scanner.Err()
|
|
}
|
|
|
|
// PlainMatcher returns a PasswordMatcher that does a constant-time
|
|
// byte comparison against the password passw.
|
|
func PlainMatcher(passw string) PasswordMatcher {
|
|
// compare hashes of equal length instead of actual password
|
|
// to avoid leaking password length
|
|
passwHash := sha1.New()
|
|
passwHash.Write([]byte(passw))
|
|
passwSum := passwHash.Sum(nil)
|
|
return func(pw string) bool {
|
|
pwHash := sha1.New()
|
|
pwHash.Write([]byte(pw))
|
|
pwSum := pwHash.Sum(nil)
|
|
return subtle.ConstantTimeCompare([]byte(pwSum), []byte(passwSum)) == 1
|
|
}
|
|
}
|