mirror of
https://github.com/AdguardTeam/AdGuardHome.git
synced 2025-10-26 11:27:18 +00:00
Squashed commit of the following: commit 9324a0066202f1677bfd033d40d3a82fa9756ed9 Merge:8a1b5cad4f9da40e39Author: Stanislav Chzhen <s.chzhen@adguard.com> Date: Thu Oct 23 17:48:01 2025 +0300 Merge branch 'master' into AGDNS-3224-aghhttp-register-slog commit8a1b5cad4cAuthor: Stanislav Chzhen <s.chzhen@adguard.com> Date: Tue Oct 21 15:51:48 2025 +0300 filtering: imp code commitfe569166efMerge:9a101a2f59be4ca90eAuthor: Stanislav Chzhen <s.chzhen@adguard.com> Date: Tue Oct 21 15:45:42 2025 +0300 Merge branch 'master' into AGDNS-3224-aghhttp-register-slog commit9a101a2f5fAuthor: Stanislav Chzhen <s.chzhen@adguard.com> Date: Wed Oct 15 18:52:22 2025 +0300 home: imp code commit727e1663baAuthor: Stanislav Chzhen <s.chzhen@adguard.com> Date: Wed Oct 15 10:19:56 2025 +0300 all: imp code commit113a9017dfAuthor: Stanislav Chzhen <s.chzhen@adguard.com> Date: Mon Oct 13 23:10:06 2025 +0300 home: fix typo commit6588dd2dadAuthor: Stanislav Chzhen <s.chzhen@adguard.com> Date: Mon Oct 13 22:46:28 2025 +0300 all: imp naming commit44278505a9Author: Stanislav Chzhen <s.chzhen@adguard.com> Date: Fri Oct 10 16:20:17 2025 +0300 home: fix typo commit7b4b57628bAuthor: Stanislav Chzhen <s.chzhen@adguard.com> Date: Fri Oct 10 15:58:07 2025 +0300 all: web mw commit93168142cbAuthor: Stanislav Chzhen <s.chzhen@adguard.com> Date: Wed Oct 8 22:20:07 2025 +0300 all: aghhttp slog commit9155edef67Author: Stanislav Chzhen <s.chzhen@adguard.com> Date: Wed Oct 8 15:38:01 2025 +0300 aghhttp: registrar commita356473855Author: Stanislav Chzhen <s.chzhen@adguard.com> Date: Tue Oct 7 15:32:30 2025 +0300 all: http registrar
531 lines
13 KiB
Go
531 lines
13 KiB
Go
package home
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/netip"
|
|
"path"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
|
|
"github.com/AdguardTeam/AdGuardHome/internal/aghuser"
|
|
"github.com/AdguardTeam/golibs/errors"
|
|
"github.com/AdguardTeam/golibs/httphdr"
|
|
"github.com/AdguardTeam/golibs/log"
|
|
"github.com/AdguardTeam/golibs/logutil/slogutil"
|
|
"github.com/AdguardTeam/golibs/netutil"
|
|
"github.com/AdguardTeam/golibs/netutil/httputil"
|
|
"github.com/AdguardTeam/golibs/timeutil"
|
|
"github.com/AdguardTeam/golibs/validate"
|
|
)
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// 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 netip.Addr, err error) {
|
|
proxyHeaders := []string{
|
|
httphdr.CFConnectingIP,
|
|
httphdr.TrueClientIP,
|
|
httphdr.XRealIP,
|
|
}
|
|
|
|
for _, h := range proxyHeaders {
|
|
v := r.Header.Get(h)
|
|
ip, err = netip.ParseAddr(v)
|
|
if err == 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)
|
|
ipStr, _, _ := strings.Cut(s, ",")
|
|
ip, err = netip.ParseAddr(ipStr)
|
|
if err == 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 netip.Addr{}, fmt.Errorf("getting ip from client addr: %w", err)
|
|
}
|
|
|
|
return netip.ParseAddr(ipStr)
|
|
}
|
|
|
|
// 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 (web *webAPI) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
req := loginJSON{}
|
|
err := json.NewDecoder(r.Body).Decode(&req)
|
|
if err != nil {
|
|
aghhttp.ErrorAndLog(ctx, web.logger, r, w, http.StatusBadRequest, "json decode: %s", err)
|
|
|
|
return
|
|
}
|
|
|
|
var remoteIP string
|
|
// The real IP address of the client [realIP] cannot be used here without
|
|
// taking trusted proxies into account due to security issues:
|
|
//
|
|
// See https://github.com/AdguardTeam/AdGuardHome/issues/2799.
|
|
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 := web.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
|
|
}
|
|
}
|
|
|
|
ip, err := realIP(r)
|
|
if err != nil {
|
|
web.logger.ErrorContext(
|
|
ctx,
|
|
"getting real ip",
|
|
"remote_ip", remoteIP,
|
|
slogutil.KeyError, err,
|
|
)
|
|
}
|
|
|
|
cookie, err := newCookie(ctx, web.auth, req, remoteIP)
|
|
if err != nil {
|
|
logIP := remoteIP
|
|
if web.auth.trustedProxies.Contains(ip.Unmap()) {
|
|
logIP = ip.String()
|
|
}
|
|
|
|
writeErrorWithIP(r, w, http.StatusForbidden, logIP, "%s", err)
|
|
|
|
return
|
|
}
|
|
|
|
web.logger.InfoContext(ctx, "successful login", "user", req.Name, "ip", 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(ctx, web.logger, w)
|
|
}
|
|
|
|
// newCookie creates a new authentication cookie. rateLimiter must not be nil.
|
|
func newCookie(
|
|
ctx context.Context,
|
|
auth *auth,
|
|
req loginJSON,
|
|
addr string,
|
|
) (c *http.Cookie, err error) {
|
|
user, err := auth.users.ByLogin(ctx, aghuser.Login(req.Name))
|
|
if err != nil {
|
|
// Should not happen.
|
|
panic(err)
|
|
}
|
|
|
|
rateLimiter := auth.rateLimiter
|
|
if user == nil {
|
|
rateLimiter.inc(addr)
|
|
|
|
return nil, errInvalidLogin
|
|
}
|
|
|
|
ok := user.Password.Authenticate(ctx, req.Password)
|
|
if !ok {
|
|
rateLimiter.inc(addr)
|
|
|
|
return nil, errInvalidLogin
|
|
}
|
|
|
|
rateLimiter.remove(addr)
|
|
|
|
sess, err := auth.sessions.New(ctx, user)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &http.Cookie{
|
|
Name: sessionCookieName,
|
|
Value: hex.EncodeToString(sess.Token[:]),
|
|
Path: "/",
|
|
Expires: time.Now().Add(cookieTTL),
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
}, nil
|
|
}
|
|
|
|
// handleLogout is the handler for the GET /control/logout HTTP API.
|
|
func (web *webAPI) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
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
|
|
}
|
|
|
|
t, err := sessionTokenFromHex(c.Value)
|
|
if err != nil {
|
|
web.logger.ErrorContext(ctx, "getting token", slogutil.KeyError, err)
|
|
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
|
|
return
|
|
}
|
|
|
|
err = web.auth.sessions.DeleteByToken(ctx, t)
|
|
if err != nil {
|
|
web.logger.ErrorContext(ctx, "removing session by token", slogutil.KeyError, err)
|
|
}
|
|
|
|
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 registers authentication handlers.
|
|
func (web *webAPI) registerAuthHandlers() {
|
|
web.conf.mux.Handle(
|
|
http.MethodPost+" "+"/control/login",
|
|
web.postInstallHandler(http.HandlerFunc(web.handleLogin)),
|
|
)
|
|
web.httpReg.Register(http.MethodGet, "/control/logout", web.handleLogout)
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
|
|
// TODO(s.chzhen): Implement a more strict version.
|
|
if strings.HasPrefix(p, "/dns-query/") {
|
|
return true
|
|
}
|
|
|
|
paths := []string{
|
|
"/dns-query",
|
|
"/control/login",
|
|
"/apple/doh.mobileconfig",
|
|
"/apple/dot.mobileconfig",
|
|
"/control/install/get_addresses",
|
|
"/control/install/check_config",
|
|
"/control/install/configure",
|
|
"/install.html",
|
|
}
|
|
|
|
return isAsset || isLogin || slices.Contains(paths, p)
|
|
}
|
|
|
|
const (
|
|
// errInvalidLogin is returned when there is an invalid login attempt.
|
|
errInvalidLogin errors.Error = "invalid username or password"
|
|
)
|
|
|
|
// authMiddlewareDefaultConfig is the configuration structure for the default
|
|
// authentication middleware.
|
|
type authMiddlewareDefaultConfig struct {
|
|
// logger is used for logging the operation of the middleware. It must not
|
|
// be nil.
|
|
logger *slog.Logger
|
|
|
|
// rateLimiter manages the rate limiting for login attempts.
|
|
rateLimiter loginRateLimiter
|
|
|
|
// trustedProxies is a set of subnets considered as trusted.
|
|
//
|
|
// TODO(s.chzhen): Use it not only to pass it to the middleware but also to
|
|
// log the work of the rate limiter.
|
|
trustedProxies netutil.SubnetSet
|
|
|
|
// sessions contains web user sessions. It must not be nil.
|
|
sessions aghuser.SessionStorage
|
|
|
|
// users contains web user information. It must not be nil.
|
|
users aghuser.DB
|
|
}
|
|
|
|
// authMiddlewareDefault is the default authentication middleware. It searches
|
|
// for a web client using an authentication cookie or basic auth credentials and
|
|
// passes it with the context.
|
|
type authMiddlewareDefault struct {
|
|
logger *slog.Logger
|
|
rateLimiter loginRateLimiter
|
|
trustedProxies netutil.SubnetSet
|
|
sessions aghuser.SessionStorage
|
|
users aghuser.DB
|
|
}
|
|
|
|
// newAuthMiddlewareDefault returns the new properly initialized
|
|
// *authMiddlewareDefault.
|
|
func newAuthMiddlewareDefault(c *authMiddlewareDefaultConfig) (mw *authMiddlewareDefault) {
|
|
return &authMiddlewareDefault{
|
|
logger: c.logger,
|
|
rateLimiter: c.rateLimiter,
|
|
trustedProxies: c.trustedProxies,
|
|
sessions: c.sessions,
|
|
users: c.users,
|
|
}
|
|
}
|
|
|
|
// type check
|
|
var _ httputil.Middleware = (*authMiddlewareDefault)(nil)
|
|
|
|
// Wrap implements the [httputil.Middleware] interface for
|
|
// *authMiddlewareDefault.
|
|
func (mw *authMiddlewareDefault) Wrap(h http.Handler) (wrapped http.Handler) {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
if !mw.needsAuthentication(ctx) {
|
|
h.ServeHTTP(w, r)
|
|
|
|
return
|
|
}
|
|
|
|
path := r.URL.Path
|
|
u, err := mw.userFromRequest(ctx, r)
|
|
if err != nil {
|
|
mw.logger.ErrorContext(ctx, "retrieving user from request", slogutil.KeyError, err)
|
|
}
|
|
|
|
if u != nil {
|
|
if path == "/login.html" {
|
|
http.Redirect(w, r, "/", http.StatusFound)
|
|
|
|
return
|
|
}
|
|
|
|
h.ServeHTTP(w, r.WithContext(withWebUser(ctx, u)))
|
|
|
|
return
|
|
}
|
|
|
|
if isPublicResource(path) {
|
|
h.ServeHTTP(w, r)
|
|
|
|
return
|
|
}
|
|
|
|
if path == "/" || path == "/index.html" {
|
|
http.Redirect(w, r, "login.html", http.StatusFound)
|
|
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
})
|
|
}
|
|
|
|
// needsAuthentication returns true if there are stored web users and requests
|
|
// should be authenticated first.
|
|
func (mw *authMiddlewareDefault) needsAuthentication(ctx context.Context) (ok bool) {
|
|
users, err := mw.users.All(ctx)
|
|
if err != nil {
|
|
// Should not happen.
|
|
panic(err)
|
|
}
|
|
|
|
return len(users) != 0
|
|
}
|
|
|
|
// userFromRequest tries to retrieve a user based on the request. r must not be
|
|
// nil.
|
|
func (mw *authMiddlewareDefault) userFromRequest(
|
|
ctx context.Context,
|
|
r *http.Request,
|
|
) (u *aghuser.User, err error) {
|
|
defer func() { err = errors.Annotate(err, "getting user from request: %w") }()
|
|
|
|
cookie, err := r.Cookie(sessionCookieName)
|
|
if err == nil {
|
|
return mw.userFromCookie(ctx, cookie.Value)
|
|
}
|
|
|
|
return mw.userFromRequestBasicAuth(ctx, r)
|
|
}
|
|
|
|
// userFromCookie tries to retrieve a user based on the provided cookie value.
|
|
func (mw *authMiddlewareDefault) userFromCookie(
|
|
ctx context.Context,
|
|
val string,
|
|
) (u *aghuser.User, err error) {
|
|
t, err := sessionTokenFromHex(val)
|
|
if err != nil {
|
|
// Don't wrap the error because it's informative enough as is.
|
|
return nil, err
|
|
}
|
|
|
|
s, err := mw.sessions.FindByToken(ctx, t)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("searching session by token: %w", err)
|
|
}
|
|
|
|
if s == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
u, err = mw.users.ByLogin(ctx, s.UserLogin)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("searching user by login %q: %w", s.UserLogin, err)
|
|
}
|
|
|
|
return u, nil
|
|
}
|
|
|
|
// sessionTokenFromHex converts a hexadecimal string into a session token.
|
|
func sessionTokenFromHex(val string) (token aghuser.SessionToken, err error) {
|
|
sess, err := hex.DecodeString(val)
|
|
if err != nil {
|
|
return token, fmt.Errorf("decoding value: %w", err)
|
|
}
|
|
|
|
l := aghuser.SessionTokenLength
|
|
|
|
err = validate.Equal("token length", l, len(sess))
|
|
if err != nil {
|
|
// Don't wrap the error because it's informative enough as is.
|
|
return token, err
|
|
}
|
|
|
|
return aghuser.SessionToken(sess), nil
|
|
}
|
|
|
|
// userFromRequestBasicAuth searches for a user using Basic Auth credentials. r
|
|
// must not be nil.
|
|
func (mw *authMiddlewareDefault) userFromRequestBasicAuth(
|
|
ctx context.Context,
|
|
r *http.Request,
|
|
) (user *aghuser.User, err error) {
|
|
login, pass, ok := r.BasicAuth()
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
|
|
var remoteIP string
|
|
// The real IP address of the client [realIP] cannot be used here without
|
|
// taking trusted proxies into account due to security issues:
|
|
//
|
|
// See https://github.com/AdguardTeam/AdGuardHome/issues/2799.
|
|
if remoteIP, err = netutil.SplitHost(r.RemoteAddr); err != nil {
|
|
return nil, fmt.Errorf("getting remote address: %w", err)
|
|
}
|
|
|
|
rateLimiter := mw.rateLimiter
|
|
if left := rateLimiter.check(remoteIP); left > 0 {
|
|
return nil, fmt.Errorf("login attempt blocked for %s", left)
|
|
}
|
|
|
|
defer func() {
|
|
if err != nil {
|
|
rateLimiter.inc(remoteIP)
|
|
|
|
return
|
|
}
|
|
|
|
rateLimiter.remove(remoteIP)
|
|
}()
|
|
|
|
user, _ = mw.users.ByLogin(ctx, aghuser.Login(login))
|
|
if user == nil {
|
|
return nil, errInvalidLogin
|
|
}
|
|
|
|
ok = user.Password.Authenticate(ctx, pass)
|
|
if !ok {
|
|
return nil, errInvalidLogin
|
|
}
|
|
|
|
return user, nil
|
|
}
|