355 lines
9.0 KiB
Go
355 lines
9.0 KiB
Go
package home
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/netip"
|
|
"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 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 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.
|
|
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
|
|
}
|
|
}
|
|
|
|
ip, err := realIP(r)
|
|
if err != nil {
|
|
log.Error("auth: getting real ip from request with remote ip %s: %s", remoteIP, err)
|
|
}
|
|
|
|
cookie, err := Context.auth.newCookie(req, remoteIP)
|
|
if err != nil {
|
|
logIP := remoteIP
|
|
if Context.auth.trustedProxies.Contains(ip.Unmap()) {
|
|
logIP = ip.String()
|
|
}
|
|
|
|
writeErrorWithIP(r, w, http.StatusForbidden, logIP, "%s", err)
|
|
|
|
return
|
|
}
|
|
|
|
log.Info("auth: user %q successfully logged in from ip %s", 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}
|
|
}
|