Pull request 2053: 6357-auth-log-remote-ip
Updates #6357.
Squashed commit of the following:
commit 0d375446204d126d3fc20db0a0718e849112450b
Merge: 61858bdec 52713a260
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date: Fri Nov 3 14:47:10 2023 +0300
Merge branch 'master' into 6357-auth-log-remote-ip
commit 61858bdec27f9efb35c6fa5306ace1c0053300ca
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date: Fri Nov 3 14:44:58 2023 +0300
all: upd chlog
commit 1eef67261ff1e4eb667e11a58a5fe1f9b1dbdd7c
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date: Thu Nov 2 19:20:41 2023 +0300
home: imp code
commit 2956aed9054309ab15dc9e61bcae59b76ccd5930
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date: Thu Nov 2 16:10:07 2023 +0300
home: imp docs
commit ca0f53d7c28d17287d80c0c5d1d76b21506acb64
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date: Tue Oct 31 15:08:37 2023 +0300
home: imp code
commit 6b11b461180f1ee7528ffbaf37d5e76a1a7f208a
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date: Fri Oct 27 19:45:55 2023 +0300
home: auth log remote ip
This commit is contained in:
parent
52713a2600
commit
f3817e4411
|
@ -28,6 +28,10 @@ NOTE: Add new changes BELOW THIS COMMENT.
|
||||||
- Ability to specify multiple domain specific upstreams per line, e.g.
|
- Ability to specify multiple domain specific upstreams per line, e.g.
|
||||||
`[/domain1/../domain2/]upstream1 upstream2 .. upstreamN` ([#4977]).
|
`[/domain1/../domain2/]upstream1 upstream2 .. upstreamN` ([#4977]).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved authentication failure logging ([#6357]).
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- `$important,dnsrewrite` rules do not take precedence over allowlist rules
|
- `$important,dnsrewrite` rules do not take precedence over allowlist rules
|
||||||
|
@ -39,6 +43,7 @@ NOTE: Add new changes BELOW THIS COMMENT.
|
||||||
[#6204]: https://github.com/AdguardTeam/AdGuardHome/issues/6204
|
[#6204]: https://github.com/AdguardTeam/AdGuardHome/issues/6204
|
||||||
[#6329]: https://github.com/AdguardTeam/AdGuardHome/issues/6329
|
[#6329]: https://github.com/AdguardTeam/AdGuardHome/issues/6329
|
||||||
[#6335]: https://github.com/AdguardTeam/AdGuardHome/issues/6335
|
[#6335]: https://github.com/AdguardTeam/AdGuardHome/issues/6335
|
||||||
|
[#6357]: https://github.com/AdguardTeam/AdGuardHome/issues/6357
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
NOTE: Add new changes ABOVE THIS COMMENT.
|
NOTE: Add new changes ABOVE THIS COMMENT.
|
||||||
|
|
|
@ -4,32 +4,17 @@ import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
|
|
||||||
"github.com/AdguardTeam/golibs/errors"
|
"github.com/AdguardTeam/golibs/errors"
|
||||||
"github.com/AdguardTeam/golibs/httphdr"
|
|
||||||
"github.com/AdguardTeam/golibs/log"
|
"github.com/AdguardTeam/golibs/log"
|
||||||
"github.com/AdguardTeam/golibs/netutil"
|
|
||||||
"github.com/AdguardTeam/golibs/timeutil"
|
|
||||||
"go.etcd.io/bbolt"
|
"go.etcd.io/bbolt"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// cookieTTL is the time-to-live of the session cookie.
|
|
||||||
const cookieTTL = 365 * timeutil.Day
|
|
||||||
|
|
||||||
// sessionCookieName is the name of the session cookie.
|
|
||||||
const sessionCookieName = "agh_session"
|
|
||||||
|
|
||||||
// sessionTokenSize is the length of session token in bytes.
|
// sessionTokenSize is the length of session token in bytes.
|
||||||
const sessionTokenSize = 16
|
const sessionTokenSize = 16
|
||||||
|
|
||||||
|
@ -69,7 +54,7 @@ func (s *session) deserialize(data []byte) bool {
|
||||||
// Auth - global object
|
// Auth - global object
|
||||||
type Auth struct {
|
type Auth struct {
|
||||||
db *bbolt.DB
|
db *bbolt.DB
|
||||||
raleLimiter *authRateLimiter
|
rateLimiter *authRateLimiter
|
||||||
sessions map[string]*session
|
sessions map[string]*session
|
||||||
users []webUser
|
users []webUser
|
||||||
lock sync.Mutex
|
lock sync.Mutex
|
||||||
|
@ -77,6 +62,8 @@ type Auth struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// webUser represents a user of the Web UI.
|
// webUser represents a user of the Web UI.
|
||||||
|
//
|
||||||
|
// TODO(s.chzhen): Improve naming.
|
||||||
type webUser struct {
|
type webUser struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
PasswordHash string `yaml:"password"`
|
PasswordHash string `yaml:"password"`
|
||||||
|
@ -88,7 +75,7 @@ func InitAuth(dbFilename string, users []webUser, sessionTTL uint32, rateLimiter
|
||||||
|
|
||||||
a := &Auth{
|
a := &Auth{
|
||||||
sessionTTL: sessionTTL,
|
sessionTTL: sessionTTL,
|
||||||
raleLimiter: rateLimiter,
|
rateLimiter: rateLimiter,
|
||||||
sessions: make(map[string]*session),
|
sessions: make(map[string]*session),
|
||||||
users: users,
|
users: users,
|
||||||
}
|
}
|
||||||
|
@ -216,8 +203,8 @@ func (a *Auth) storeSession(data []byte, s *session) bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove session from file
|
// removeSessionFromFile removes a stored session from the DB file on disk.
|
||||||
func (a *Auth) removeSession(sess []byte) {
|
func (a *Auth) removeSessionFromFile(sess []byte) {
|
||||||
tx, err := a.db.Begin(true)
|
tx, err := a.db.Begin(true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("auth: bbolt.Begin: %s", err)
|
log.Error("auth: bbolt.Begin: %s", err)
|
||||||
|
@ -279,7 +266,7 @@ func (a *Auth) checkSession(sess string) (res checkSessionResult) {
|
||||||
if s.expire <= now {
|
if s.expire <= now {
|
||||||
delete(a.sessions, sess)
|
delete(a.sessions, sess)
|
||||||
key, _ := hex.DecodeString(sess)
|
key, _ := hex.DecodeString(sess)
|
||||||
a.removeSession(key)
|
a.removeSessionFromFile(key)
|
||||||
|
|
||||||
return checkSessionExpired
|
return checkSessionExpired
|
||||||
}
|
}
|
||||||
|
@ -301,351 +288,17 @@ func (a *Auth) checkSession(sess string) (res checkSessionResult) {
|
||||||
return checkSessionOK
|
return checkSessionOK
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveSession - remove session
|
// removeSession removes the session from the active sessions and the disk.
|
||||||
func (a *Auth) RemoveSession(sess string) {
|
func (a *Auth) removeSession(sess string) {
|
||||||
key, _ := hex.DecodeString(sess)
|
key, _ := hex.DecodeString(sess)
|
||||||
a.lock.Lock()
|
a.lock.Lock()
|
||||||
delete(a.sessions, sess)
|
delete(a.sessions, sess)
|
||||||
a.lock.Unlock()
|
a.lock.Unlock()
|
||||||
a.removeSession(key)
|
a.removeSessionFromFile(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
type loginJSON struct {
|
// addUser adds a new user with the given password.
|
||||||
Name string `json:"name"`
|
func (a *Auth) addUser(u *webUser, password string) (err error) {
|
||||||
Password string `json:"password"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// newSessionToken returns cryptographically secure randomly generated slice of
|
|
||||||
// bytes of sessionTokenSize length.
|
|
||||||
//
|
|
||||||
// TODO(e.burkov): Think about using byte array instead of byte slice.
|
|
||||||
func newSessionToken() (data []byte, err error) {
|
|
||||||
randData := make([]byte, sessionTokenSize)
|
|
||||||
|
|
||||||
_, err = rand.Read(randData)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return randData, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// newCookie creates a new authentication cookie.
|
|
||||||
func (a *Auth) newCookie(req loginJSON, addr string) (c *http.Cookie, err error) {
|
|
||||||
rateLimiter := a.raleLimiter
|
|
||||||
u, ok := a.findUser(req.Name, req.Password)
|
|
||||||
if !ok {
|
|
||||||
if rateLimiter != nil {
|
|
||||||
rateLimiter.inc(addr)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, errors.Error("invalid username or password")
|
|
||||||
}
|
|
||||||
|
|
||||||
if rateLimiter != nil {
|
|
||||||
rateLimiter.remove(addr)
|
|
||||||
}
|
|
||||||
|
|
||||||
sess, err := newSessionToken()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("generating token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().UTC()
|
|
||||||
|
|
||||||
a.addSession(sess, &session{
|
|
||||||
userName: u.Name,
|
|
||||||
expire: uint32(now.Unix()) + a.sessionTTL,
|
|
||||||
})
|
|
||||||
|
|
||||||
return &http.Cookie{
|
|
||||||
Name: sessionCookieName,
|
|
||||||
Value: hex.EncodeToString(sess),
|
|
||||||
Path: "/",
|
|
||||||
Expires: now.Add(cookieTTL),
|
|
||||||
|
|
||||||
HttpOnly: true,
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// realIP extracts the real IP address of the client from an HTTP request using
|
|
||||||
// the known HTTP headers.
|
|
||||||
//
|
|
||||||
// TODO(a.garipov): Currently, this is basically a copy of a similar function in
|
|
||||||
// module dnsproxy. This should really become a part of module golibs and be
|
|
||||||
// replaced both here and there. Or be replaced in both places by
|
|
||||||
// a well-maintained third-party module.
|
|
||||||
//
|
|
||||||
// TODO(a.garipov): Support header Forwarded from RFC 7329.
|
|
||||||
func realIP(r *http.Request) (ip net.IP, err error) {
|
|
||||||
proxyHeaders := []string{
|
|
||||||
httphdr.CFConnectingIP,
|
|
||||||
httphdr.TrueClientIP,
|
|
||||||
httphdr.XRealIP,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, h := range proxyHeaders {
|
|
||||||
v := r.Header.Get(h)
|
|
||||||
ip = net.ParseIP(v)
|
|
||||||
if ip != nil {
|
|
||||||
return ip, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If none of the above yielded any results, get the leftmost IP address
|
|
||||||
// from the X-Forwarded-For header.
|
|
||||||
s := r.Header.Get(httphdr.XForwardedFor)
|
|
||||||
ipStrs := strings.SplitN(s, ", ", 2)
|
|
||||||
ip = net.ParseIP(ipStrs[0])
|
|
||||||
if ip != nil {
|
|
||||||
return ip, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// When everything else fails, just return the remote address as understood
|
|
||||||
// by the stdlib.
|
|
||||||
ipStr, err := netutil.SplitHost(r.RemoteAddr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("getting ip from client addr: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return net.ParseIP(ipStr), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeErrorWithIP is like [aghhttp.Error], but includes the remote IP address
|
|
||||||
// when it writes to the log.
|
|
||||||
func writeErrorWithIP(
|
|
||||||
r *http.Request,
|
|
||||||
w http.ResponseWriter,
|
|
||||||
code int,
|
|
||||||
remoteIP string,
|
|
||||||
format string,
|
|
||||||
args ...any,
|
|
||||||
) {
|
|
||||||
text := fmt.Sprintf(format, args...)
|
|
||||||
log.Error("%s %s %s: from ip %s: %s", r.Method, r.Host, r.URL, remoteIP, text)
|
|
||||||
http.Error(w, text, code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
||||||
req := loginJSON{}
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&req)
|
|
||||||
if err != nil {
|
|
||||||
aghhttp.Error(r, w, http.StatusBadRequest, "json decode: %s", err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var remoteIP string
|
|
||||||
// realIP cannot be used here without taking TrustedProxies into account due
|
|
||||||
// to security issues.
|
|
||||||
//
|
|
||||||
// See https://github.com/AdguardTeam/AdGuardHome/issues/2799.
|
|
||||||
//
|
|
||||||
// TODO(e.burkov): Use realIP when the issue will be fixed.
|
|
||||||
if remoteIP, err = netutil.SplitHost(r.RemoteAddr); err != nil {
|
|
||||||
writeErrorWithIP(
|
|
||||||
r,
|
|
||||||
w,
|
|
||||||
http.StatusBadRequest,
|
|
||||||
r.RemoteAddr,
|
|
||||||
"auth: getting remote address: %s",
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if rateLimiter := Context.auth.raleLimiter; rateLimiter != nil {
|
|
||||||
if left := rateLimiter.check(remoteIP); left > 0 {
|
|
||||||
w.Header().Set(httphdr.RetryAfter, strconv.Itoa(int(left.Seconds())))
|
|
||||||
writeErrorWithIP(
|
|
||||||
r,
|
|
||||||
w,
|
|
||||||
http.StatusTooManyRequests,
|
|
||||||
remoteIP,
|
|
||||||
"auth: blocked for %s",
|
|
||||||
left,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cookie, err := Context.auth.newCookie(req, remoteIP)
|
|
||||||
if err != nil {
|
|
||||||
writeErrorWithIP(r, w, http.StatusForbidden, remoteIP, "%s", err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use realIP here, since this IP address is only used for logging.
|
|
||||||
ip, err := realIP(r)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("auth: getting real ip from request with remote ip %s: %s", remoteIP, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info("auth: user %q successfully logged in from ip %v", req.Name, ip)
|
|
||||||
|
|
||||||
http.SetCookie(w, cookie)
|
|
||||||
|
|
||||||
h := w.Header()
|
|
||||||
h.Set(httphdr.CacheControl, "no-store, no-cache, must-revalidate, proxy-revalidate")
|
|
||||||
h.Set(httphdr.Pragma, "no-cache")
|
|
||||||
h.Set(httphdr.Expires, "0")
|
|
||||||
|
|
||||||
aghhttp.OK(w)
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
||||||
respHdr := w.Header()
|
|
||||||
c, err := r.Cookie(sessionCookieName)
|
|
||||||
if err != nil {
|
|
||||||
// The only error that is returned from r.Cookie is [http.ErrNoCookie].
|
|
||||||
// The user is already logged out.
|
|
||||||
respHdr.Set(httphdr.Location, "/login.html")
|
|
||||||
w.WriteHeader(http.StatusFound)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Context.auth.RemoveSession(c.Value)
|
|
||||||
|
|
||||||
c = &http.Cookie{
|
|
||||||
Name: sessionCookieName,
|
|
||||||
Value: "",
|
|
||||||
Path: "/",
|
|
||||||
Expires: time.Unix(0, 0),
|
|
||||||
|
|
||||||
HttpOnly: true,
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
}
|
|
||||||
|
|
||||||
respHdr.Set(httphdr.Location, "/login.html")
|
|
||||||
respHdr.Set(httphdr.SetCookie, c.String())
|
|
||||||
w.WriteHeader(http.StatusFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterAuthHandlers - register handlers
|
|
||||||
func RegisterAuthHandlers() {
|
|
||||||
Context.mux.Handle("/control/login", postInstallHandler(ensureHandler(http.MethodPost, handleLogin)))
|
|
||||||
httpRegister(http.MethodGet, "/control/logout", handleLogout)
|
|
||||||
}
|
|
||||||
|
|
||||||
// optionalAuthThird return true if user should authenticate first.
|
|
||||||
func optionalAuthThird(w http.ResponseWriter, r *http.Request) (mustAuth bool) {
|
|
||||||
if glProcessCookie(r) {
|
|
||||||
log.Debug("auth: authentication is handled by GL-Inet submodule")
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// redirect to login page if not authenticated
|
|
||||||
isAuthenticated := false
|
|
||||||
cookie, err := r.Cookie(sessionCookieName)
|
|
||||||
if err != nil {
|
|
||||||
// The only error that is returned from r.Cookie is [http.ErrNoCookie].
|
|
||||||
// Check Basic authentication.
|
|
||||||
user, pass, hasBasic := r.BasicAuth()
|
|
||||||
if hasBasic {
|
|
||||||
_, isAuthenticated = Context.auth.findUser(user, pass)
|
|
||||||
if !isAuthenticated {
|
|
||||||
log.Info("auth: invalid Basic Authorization value")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
res := Context.auth.checkSession(cookie.Value)
|
|
||||||
isAuthenticated = res == checkSessionOK
|
|
||||||
if !isAuthenticated {
|
|
||||||
log.Debug("auth: invalid cookie value: %s", cookie)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if isAuthenticated {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if p := r.URL.Path; p == "/" || p == "/index.html" {
|
|
||||||
if glProcessRedirect(w, r) {
|
|
||||||
log.Debug("auth: redirected to login page by GL-Inet submodule")
|
|
||||||
} else {
|
|
||||||
log.Debug("auth: redirected to login page")
|
|
||||||
http.Redirect(w, r, "login.html", http.StatusFound)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.Debug("auth: responded with forbidden to %s %s", r.Method, p)
|
|
||||||
w.WriteHeader(http.StatusForbidden)
|
|
||||||
_, _ = w.Write([]byte("Forbidden"))
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO(a.garipov): Use [http.Handler] consistently everywhere throughout the
|
|
||||||
// project.
|
|
||||||
func optionalAuth(
|
|
||||||
h func(http.ResponseWriter, *http.Request),
|
|
||||||
) (wrapped func(http.ResponseWriter, *http.Request)) {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
p := r.URL.Path
|
|
||||||
authRequired := Context.auth != nil && Context.auth.AuthRequired()
|
|
||||||
if p == "/login.html" {
|
|
||||||
cookie, err := r.Cookie(sessionCookieName)
|
|
||||||
if authRequired && err == nil {
|
|
||||||
// Redirect to the dashboard if already authenticated.
|
|
||||||
res := Context.auth.checkSession(cookie.Value)
|
|
||||||
if res == checkSessionOK {
|
|
||||||
http.Redirect(w, r, "", http.StatusFound)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Debug("auth: invalid cookie value: %s", cookie)
|
|
||||||
}
|
|
||||||
} else if isPublicResource(p) {
|
|
||||||
// Process as usual, no additional auth requirements.
|
|
||||||
} else if authRequired {
|
|
||||||
if optionalAuthThird(w, r) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h(w, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// isPublicResource returns true if p is a path to a public resource.
|
|
||||||
func isPublicResource(p string) (ok bool) {
|
|
||||||
isAsset, err := path.Match("/assets/*", p)
|
|
||||||
if err != nil {
|
|
||||||
// The only error that is returned from path.Match is
|
|
||||||
// [path.ErrBadPattern]. This is a programmer error.
|
|
||||||
panic(fmt.Errorf("bad asset pattern: %w", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
isLogin, err := path.Match("/login.*", p)
|
|
||||||
if err != nil {
|
|
||||||
// Same as above.
|
|
||||||
panic(fmt.Errorf("bad login pattern: %w", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
return isAsset || isLogin
|
|
||||||
}
|
|
||||||
|
|
||||||
type authHandler struct {
|
|
||||||
handler http.Handler
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *authHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
||||||
optionalAuth(a.handler.ServeHTTP)(w, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
func optionalAuthHandler(handler http.Handler) http.Handler {
|
|
||||||
return &authHandler{handler}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add adds a new user with the given password.
|
|
||||||
func (a *Auth) Add(u *webUser, password string) (err error) {
|
|
||||||
if len(password) == 0 {
|
if len(password) == 0 {
|
||||||
return errors.Error("empty password")
|
return errors.Error("empty password")
|
||||||
}
|
}
|
||||||
|
@ -715,22 +368,40 @@ func (a *Auth) getCurrentUser(r *http.Request) (u webUser) {
|
||||||
return webUser{}
|
return webUser{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUsers - get users
|
// usersList returns a copy of a users list.
|
||||||
func (a *Auth) GetUsers() []webUser {
|
func (a *Auth) usersList() (users []webUser) {
|
||||||
a.lock.Lock()
|
a.lock.Lock()
|
||||||
users := a.users
|
defer a.lock.Unlock()
|
||||||
a.lock.Unlock()
|
|
||||||
|
users = make([]webUser, len(a.users))
|
||||||
|
copy(users, a.users)
|
||||||
|
|
||||||
return users
|
return users
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthRequired - if authentication is required
|
// authRequired returns true if a authentication is required.
|
||||||
func (a *Auth) AuthRequired() bool {
|
func (a *Auth) authRequired() bool {
|
||||||
if GLMode {
|
if GLMode {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
a.lock.Lock()
|
a.lock.Lock()
|
||||||
r := (len(a.users) != 0)
|
defer a.lock.Unlock()
|
||||||
a.lock.Unlock()
|
|
||||||
return r
|
return len(a.users) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// newSessionToken returns cryptographically secure randomly generated slice of
|
||||||
|
// bytes of sessionTokenSize length.
|
||||||
|
//
|
||||||
|
// TODO(e.burkov): Think about using byte array instead of byte slice.
|
||||||
|
func newSessionToken() (data []byte, err error) {
|
||||||
|
randData := make([]byte, sessionTokenSize)
|
||||||
|
|
||||||
|
_, err = rand.Read(randData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return randData, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,89 @@
|
||||||
|
package home
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewSessionToken(t *testing.T) {
|
||||||
|
// Successful case.
|
||||||
|
token, err := newSessionToken()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, token, sessionTokenSize)
|
||||||
|
|
||||||
|
// Break the rand.Reader.
|
||||||
|
prevReader := rand.Reader
|
||||||
|
t.Cleanup(func() { rand.Reader = prevReader })
|
||||||
|
rand.Reader = &bytes.Buffer{}
|
||||||
|
|
||||||
|
// Unsuccessful case.
|
||||||
|
token, err = newSessionToken()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Empty(t, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuth(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
fn := filepath.Join(dir, "sessions.db")
|
||||||
|
|
||||||
|
users := []webUser{{
|
||||||
|
Name: "name",
|
||||||
|
PasswordHash: "$2y$05$..vyzAECIhJPfaQiOK17IukcQnqEgKJHy0iETyYqxn3YXJl8yZuo2",
|
||||||
|
}}
|
||||||
|
a := InitAuth(fn, nil, 60, nil)
|
||||||
|
s := session{}
|
||||||
|
|
||||||
|
user := webUser{Name: "name"}
|
||||||
|
err := a.addUser(&user, "password")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, checkSessionNotFound, a.checkSession("notfound"))
|
||||||
|
a.removeSession("notfound")
|
||||||
|
|
||||||
|
sess, err := newSessionToken()
|
||||||
|
require.NoError(t, err)
|
||||||
|
sessStr := hex.EncodeToString(sess)
|
||||||
|
|
||||||
|
now := time.Now().UTC().Unix()
|
||||||
|
// check expiration
|
||||||
|
s.expire = uint32(now)
|
||||||
|
a.addSession(sess, &s)
|
||||||
|
assert.Equal(t, checkSessionExpired, a.checkSession(sessStr))
|
||||||
|
|
||||||
|
// add session with TTL = 2 sec
|
||||||
|
s = session{}
|
||||||
|
s.expire = uint32(time.Now().UTC().Unix() + 2)
|
||||||
|
a.addSession(sess, &s)
|
||||||
|
assert.Equal(t, checkSessionOK, a.checkSession(sessStr))
|
||||||
|
|
||||||
|
a.Close()
|
||||||
|
|
||||||
|
// load saved session
|
||||||
|
a = InitAuth(fn, users, 60, nil)
|
||||||
|
|
||||||
|
// the session is still alive
|
||||||
|
assert.Equal(t, checkSessionOK, a.checkSession(sessStr))
|
||||||
|
// reset our expiration time because checkSession() has just updated it
|
||||||
|
s.expire = uint32(time.Now().UTC().Unix() + 2)
|
||||||
|
a.storeSession(sess, &s)
|
||||||
|
a.Close()
|
||||||
|
|
||||||
|
u, ok := a.findUser("name", "password")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.NotEmpty(t, u.Name)
|
||||||
|
|
||||||
|
time.Sleep(3 * time.Second)
|
||||||
|
|
||||||
|
// load and remove expired sessions
|
||||||
|
a = InitAuth(fn, users, 60, nil)
|
||||||
|
assert.Equal(t, checkSessionNotFound, a.checkSession(sessStr))
|
||||||
|
|
||||||
|
a.Close()
|
||||||
|
}
|
|
@ -0,0 +1,352 @@
|
||||||
|
package home
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
|
||||||
|
"github.com/AdguardTeam/golibs/errors"
|
||||||
|
"github.com/AdguardTeam/golibs/httphdr"
|
||||||
|
"github.com/AdguardTeam/golibs/log"
|
||||||
|
"github.com/AdguardTeam/golibs/netutil"
|
||||||
|
"github.com/AdguardTeam/golibs/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cookieTTL is the time-to-live of the session cookie.
|
||||||
|
const cookieTTL = 365 * timeutil.Day
|
||||||
|
|
||||||
|
// sessionCookieName is the name of the session cookie.
|
||||||
|
const sessionCookieName = "agh_session"
|
||||||
|
|
||||||
|
// loginJSON is the JSON structure for authentication.
|
||||||
|
type loginJSON struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// newCookie creates a new authentication cookie.
|
||||||
|
func (a *Auth) newCookie(req loginJSON, addr string) (c *http.Cookie, err error) {
|
||||||
|
rateLimiter := a.rateLimiter
|
||||||
|
u, ok := a.findUser(req.Name, req.Password)
|
||||||
|
if !ok {
|
||||||
|
if rateLimiter != nil {
|
||||||
|
rateLimiter.inc(addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.Error("invalid username or password")
|
||||||
|
}
|
||||||
|
|
||||||
|
if rateLimiter != nil {
|
||||||
|
rateLimiter.remove(addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
sess, err := newSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("generating token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
a.addSession(sess, &session{
|
||||||
|
userName: u.Name,
|
||||||
|
expire: uint32(now.Unix()) + a.sessionTTL,
|
||||||
|
})
|
||||||
|
|
||||||
|
return &http.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: hex.EncodeToString(sess),
|
||||||
|
Path: "/",
|
||||||
|
Expires: now.Add(cookieTTL),
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// realIP extracts the real IP address of the client from an HTTP request using
|
||||||
|
// the known HTTP headers.
|
||||||
|
//
|
||||||
|
// TODO(a.garipov): Currently, this is basically a copy of a similar function in
|
||||||
|
// module dnsproxy. This should really become a part of module golibs and be
|
||||||
|
// replaced both here and there. Or be replaced in both places by
|
||||||
|
// a well-maintained third-party module.
|
||||||
|
//
|
||||||
|
// TODO(a.garipov): Support header Forwarded from RFC 7329.
|
||||||
|
func realIP(r *http.Request) (ip net.IP, err error) {
|
||||||
|
proxyHeaders := []string{
|
||||||
|
httphdr.CFConnectingIP,
|
||||||
|
httphdr.TrueClientIP,
|
||||||
|
httphdr.XRealIP,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, h := range proxyHeaders {
|
||||||
|
v := r.Header.Get(h)
|
||||||
|
ip = net.ParseIP(v)
|
||||||
|
if ip != nil {
|
||||||
|
return ip, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If none of the above yielded any results, get the leftmost IP address
|
||||||
|
// from the X-Forwarded-For header.
|
||||||
|
s := r.Header.Get(httphdr.XForwardedFor)
|
||||||
|
ipStrs := strings.SplitN(s, ", ", 2)
|
||||||
|
ip = net.ParseIP(ipStrs[0])
|
||||||
|
if ip != nil {
|
||||||
|
return ip, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// When everything else fails, just return the remote address as understood
|
||||||
|
// by the stdlib.
|
||||||
|
ipStr, err := netutil.SplitHost(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("getting ip from client addr: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return net.ParseIP(ipStr), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeErrorWithIP is like [aghhttp.Error], but includes the remote IP address
|
||||||
|
// when it writes to the log.
|
||||||
|
func writeErrorWithIP(
|
||||||
|
r *http.Request,
|
||||||
|
w http.ResponseWriter,
|
||||||
|
code int,
|
||||||
|
remoteIP string,
|
||||||
|
format string,
|
||||||
|
args ...any,
|
||||||
|
) {
|
||||||
|
text := fmt.Sprintf(format, args...)
|
||||||
|
log.Error("%s %s %s: from ip %s: %s", r.Method, r.Host, r.URL, remoteIP, text)
|
||||||
|
http.Error(w, text, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLogin is the handler for the POST /control/login HTTP API.
|
||||||
|
func handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
req := loginJSON{}
|
||||||
|
err := json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
if err != nil {
|
||||||
|
aghhttp.Error(r, w, http.StatusBadRequest, "json decode: %s", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var remoteIP string
|
||||||
|
// realIP cannot be used here without taking TrustedProxies into account due
|
||||||
|
// to security issues.
|
||||||
|
//
|
||||||
|
// See https://github.com/AdguardTeam/AdGuardHome/issues/2799.
|
||||||
|
//
|
||||||
|
// TODO(e.burkov): Use realIP when the issue will be fixed.
|
||||||
|
if remoteIP, err = netutil.SplitHost(r.RemoteAddr); err != nil {
|
||||||
|
writeErrorWithIP(
|
||||||
|
r,
|
||||||
|
w,
|
||||||
|
http.StatusBadRequest,
|
||||||
|
r.RemoteAddr,
|
||||||
|
"auth: getting remote address: %s",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if rateLimiter := Context.auth.rateLimiter; rateLimiter != nil {
|
||||||
|
if left := rateLimiter.check(remoteIP); left > 0 {
|
||||||
|
w.Header().Set(httphdr.RetryAfter, strconv.Itoa(int(left.Seconds())))
|
||||||
|
writeErrorWithIP(
|
||||||
|
r,
|
||||||
|
w,
|
||||||
|
http.StatusTooManyRequests,
|
||||||
|
remoteIP,
|
||||||
|
"auth: blocked for %s",
|
||||||
|
left,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cookie, err := Context.auth.newCookie(req, remoteIP)
|
||||||
|
if err != nil {
|
||||||
|
writeErrorWithIP(r, w, http.StatusForbidden, remoteIP, "%s", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use realIP here, since this IP address is only used for logging.
|
||||||
|
ip, err := realIP(r)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("auth: getting real ip from request with remote ip %s: %s", remoteIP, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("auth: user %q successfully logged in from ip %v", req.Name, ip)
|
||||||
|
|
||||||
|
http.SetCookie(w, cookie)
|
||||||
|
|
||||||
|
h := w.Header()
|
||||||
|
h.Set(httphdr.CacheControl, "no-store, no-cache, must-revalidate, proxy-revalidate")
|
||||||
|
h.Set(httphdr.Pragma, "no-cache")
|
||||||
|
h.Set(httphdr.Expires, "0")
|
||||||
|
|
||||||
|
aghhttp.OK(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLogout is the handler for the GET /control/logout HTTP API.
|
||||||
|
func handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
respHdr := w.Header()
|
||||||
|
c, err := r.Cookie(sessionCookieName)
|
||||||
|
if err != nil {
|
||||||
|
// The only error that is returned from r.Cookie is [http.ErrNoCookie].
|
||||||
|
// The user is already logged out.
|
||||||
|
respHdr.Set(httphdr.Location, "/login.html")
|
||||||
|
w.WriteHeader(http.StatusFound)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Context.auth.removeSession(c.Value)
|
||||||
|
|
||||||
|
c = &http.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
Expires: time.Unix(0, 0),
|
||||||
|
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
respHdr.Set(httphdr.Location, "/login.html")
|
||||||
|
respHdr.Set(httphdr.SetCookie, c.String())
|
||||||
|
w.WriteHeader(http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterAuthHandlers - register handlers
|
||||||
|
func RegisterAuthHandlers() {
|
||||||
|
Context.mux.Handle("/control/login", postInstallHandler(ensureHandler(http.MethodPost, handleLogin)))
|
||||||
|
httpRegister(http.MethodGet, "/control/logout", handleLogout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// optionalAuthThird returns true if a user should authenticate first.
|
||||||
|
func optionalAuthThird(w http.ResponseWriter, r *http.Request) (mustAuth bool) {
|
||||||
|
pref := fmt.Sprintf("auth: raddr %s", r.RemoteAddr)
|
||||||
|
|
||||||
|
if glProcessCookie(r) {
|
||||||
|
log.Debug("%s: authentication is handled by gl-inet submodule", pref)
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// redirect to login page if not authenticated
|
||||||
|
isAuthenticated := false
|
||||||
|
cookie, err := r.Cookie(sessionCookieName)
|
||||||
|
if err != nil {
|
||||||
|
// The only error that is returned from r.Cookie is [http.ErrNoCookie].
|
||||||
|
// Check Basic authentication.
|
||||||
|
user, pass, hasBasic := r.BasicAuth()
|
||||||
|
if hasBasic {
|
||||||
|
_, isAuthenticated = Context.auth.findUser(user, pass)
|
||||||
|
if !isAuthenticated {
|
||||||
|
log.Info("%s: invalid basic authorization value", pref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
res := Context.auth.checkSession(cookie.Value)
|
||||||
|
isAuthenticated = res == checkSessionOK
|
||||||
|
if !isAuthenticated {
|
||||||
|
log.Debug("%s: invalid cookie value: %q", pref, cookie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isAuthenticated {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if p := r.URL.Path; p == "/" || p == "/index.html" {
|
||||||
|
if glProcessRedirect(w, r) {
|
||||||
|
log.Debug("%s: redirected to login page by gl-inet submodule", pref)
|
||||||
|
} else {
|
||||||
|
log.Debug("%s: redirected to login page", pref)
|
||||||
|
http.Redirect(w, r, "login.html", http.StatusFound)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Debug("%s: responded with forbidden to %s %s", pref, r.Method, p)
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
_, _ = w.Write([]byte("Forbidden"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(a.garipov): Use [http.Handler] consistently everywhere throughout the
|
||||||
|
// project.
|
||||||
|
func optionalAuth(
|
||||||
|
h func(http.ResponseWriter, *http.Request),
|
||||||
|
) (wrapped func(http.ResponseWriter, *http.Request)) {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := r.URL.Path
|
||||||
|
authRequired := Context.auth != nil && Context.auth.authRequired()
|
||||||
|
if p == "/login.html" {
|
||||||
|
cookie, err := r.Cookie(sessionCookieName)
|
||||||
|
if authRequired && err == nil {
|
||||||
|
// Redirect to the dashboard if already authenticated.
|
||||||
|
res := Context.auth.checkSession(cookie.Value)
|
||||||
|
if res == checkSessionOK {
|
||||||
|
http.Redirect(w, r, "", http.StatusFound)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug("auth: raddr %s: invalid cookie value: %q", r.RemoteAddr, cookie)
|
||||||
|
}
|
||||||
|
} else if isPublicResource(p) {
|
||||||
|
// Process as usual, no additional auth requirements.
|
||||||
|
} else if authRequired {
|
||||||
|
if optionalAuthThird(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPublicResource returns true if p is a path to a public resource.
|
||||||
|
func isPublicResource(p string) (ok bool) {
|
||||||
|
isAsset, err := path.Match("/assets/*", p)
|
||||||
|
if err != nil {
|
||||||
|
// The only error that is returned from path.Match is
|
||||||
|
// [path.ErrBadPattern]. This is a programmer error.
|
||||||
|
panic(fmt.Errorf("bad asset pattern: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
isLogin, err := path.Match("/login.*", p)
|
||||||
|
if err != nil {
|
||||||
|
// Same as above.
|
||||||
|
panic(fmt.Errorf("bad login pattern: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return isAsset || isLogin
|
||||||
|
}
|
||||||
|
|
||||||
|
// authHandler is a helper structure that implements [http.Handler].
|
||||||
|
type authHandler struct {
|
||||||
|
handler http.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP implements the [http.Handler] interface for *authHandler.
|
||||||
|
func (a *authHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
optionalAuth(a.handler.ServeHTTP)(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// optionalAuthHandler returns a authentication handler.
|
||||||
|
func optionalAuthHandler(handler http.Handler) http.Handler {
|
||||||
|
return &authHandler{handler}
|
||||||
|
}
|
|
@ -1,16 +1,12 @@
|
||||||
package home
|
package home
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/textproto"
|
"net/textproto"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/AdguardTeam/golibs/httphdr"
|
"github.com/AdguardTeam/golibs/httphdr"
|
||||||
"github.com/AdguardTeam/golibs/testutil"
|
"github.com/AdguardTeam/golibs/testutil"
|
||||||
|
@ -18,82 +14,6 @@ import (
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewSessionToken(t *testing.T) {
|
|
||||||
// Successful case.
|
|
||||||
token, err := newSessionToken()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, token, sessionTokenSize)
|
|
||||||
|
|
||||||
// Break the rand.Reader.
|
|
||||||
prevReader := rand.Reader
|
|
||||||
t.Cleanup(func() { rand.Reader = prevReader })
|
|
||||||
rand.Reader = &bytes.Buffer{}
|
|
||||||
|
|
||||||
// Unsuccessful case.
|
|
||||||
token, err = newSessionToken()
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Empty(t, token)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuth(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
fn := filepath.Join(dir, "sessions.db")
|
|
||||||
|
|
||||||
users := []webUser{{
|
|
||||||
Name: "name",
|
|
||||||
PasswordHash: "$2y$05$..vyzAECIhJPfaQiOK17IukcQnqEgKJHy0iETyYqxn3YXJl8yZuo2",
|
|
||||||
}}
|
|
||||||
a := InitAuth(fn, nil, 60, nil)
|
|
||||||
s := session{}
|
|
||||||
|
|
||||||
user := webUser{Name: "name"}
|
|
||||||
err := a.Add(&user, "password")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, checkSessionNotFound, a.checkSession("notfound"))
|
|
||||||
a.RemoveSession("notfound")
|
|
||||||
|
|
||||||
sess, err := newSessionToken()
|
|
||||||
require.NoError(t, err)
|
|
||||||
sessStr := hex.EncodeToString(sess)
|
|
||||||
|
|
||||||
now := time.Now().UTC().Unix()
|
|
||||||
// check expiration
|
|
||||||
s.expire = uint32(now)
|
|
||||||
a.addSession(sess, &s)
|
|
||||||
assert.Equal(t, checkSessionExpired, a.checkSession(sessStr))
|
|
||||||
|
|
||||||
// add session with TTL = 2 sec
|
|
||||||
s = session{}
|
|
||||||
s.expire = uint32(time.Now().UTC().Unix() + 2)
|
|
||||||
a.addSession(sess, &s)
|
|
||||||
assert.Equal(t, checkSessionOK, a.checkSession(sessStr))
|
|
||||||
|
|
||||||
a.Close()
|
|
||||||
|
|
||||||
// load saved session
|
|
||||||
a = InitAuth(fn, users, 60, nil)
|
|
||||||
|
|
||||||
// the session is still alive
|
|
||||||
assert.Equal(t, checkSessionOK, a.checkSession(sessStr))
|
|
||||||
// reset our expiration time because checkSession() has just updated it
|
|
||||||
s.expire = uint32(time.Now().UTC().Unix() + 2)
|
|
||||||
a.storeSession(sess, &s)
|
|
||||||
a.Close()
|
|
||||||
|
|
||||||
u, ok := a.findUser("name", "password")
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.NotEmpty(t, u.Name)
|
|
||||||
|
|
||||||
time.Sleep(3 * time.Second)
|
|
||||||
|
|
||||||
// load and remove expired sessions
|
|
||||||
a = InitAuth(fn, users, 60, nil)
|
|
||||||
assert.Equal(t, checkSessionNotFound, a.checkSession(sessStr))
|
|
||||||
|
|
||||||
a.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// implements http.ResponseWriter
|
// implements http.ResponseWriter
|
||||||
type testResponseWriter struct {
|
type testResponseWriter struct {
|
||||||
hdr http.Header
|
hdr http.Header
|
|
@ -587,7 +587,7 @@ func (c *configuration) write() (err error) {
|
||||||
defer c.Unlock()
|
defer c.Unlock()
|
||||||
|
|
||||||
if Context.auth != nil {
|
if Context.auth != nil {
|
||||||
config.Users = Context.auth.GetUsers()
|
config.Users = Context.auth.usersList()
|
||||||
}
|
}
|
||||||
|
|
||||||
if Context.tls != nil {
|
if Context.tls != nil {
|
||||||
|
|
|
@ -420,7 +420,7 @@ func (web *webAPI) handleInstallConfigure(w http.ResponseWriter, r *http.Request
|
||||||
u := &webUser{
|
u := &webUser{
|
||||||
Name: req.Username,
|
Name: req.Username,
|
||||||
}
|
}
|
||||||
err = Context.auth.Add(u, req.Password)
|
err = Context.auth.addUser(u, req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Context.firstRun = true
|
Context.firstRun = true
|
||||||
copyInstallSettings(config, curConfig)
|
copyInstallSettings(config, curConfig)
|
||||||
|
|
Loading…
Reference in New Issue