+ Login page and web sessions

+ /control/login
+ /control/logout
This commit is contained in:
Simon Zolin 2019-08-29 12:34:07 +03:00
parent 74381b0cad
commit 6304a7b91b
10 changed files with 577 additions and 41 deletions

View File

@ -50,6 +50,9 @@ Contents:
* API: Get filtering parameters * API: Get filtering parameters
* API: Set filtering parameters * API: Set filtering parameters
* API: Set URL parameters * API: Set URL parameters
* Log-in page
* API: Log in
* API: Log out
## Relations between subsystems ## Relations between subsystems
@ -1097,3 +1100,82 @@ Request:
Response: Response:
200 OK 200 OK
## Log-in page
After user completes the steps of installation wizard, he must log in into dashboard using his name and password. After user successfully logs in, he gets the Cookie which allows the server to authenticate him next time without password. After the Cookie is expired, user needs to perform log-in operation again. All requests without a proper Cookie get redirected to Log-In page with prompt for name and password.
YAML configuration:
users:
- name: "..."
password: "..." // bcrypt hash
...
Session DB file:
session="..." expire=123456
...
Session data is SHA(random()+name+password).
Expiration time is UNIX time when cookie gets expired.
Any request to server must come with Cookie header:
GET /...
Cookie: session=...
If not authenticated, server sends a redirect response:
302 Found
Location: /login.html
### Reset password
There is no mechanism to reset the password. Instead, the administrator must use `htpasswd` utility to generate a new hash:
htpasswd -B -n -b username password
It will print `username:<HASH>` to the terminal. `<HASH>` value may be used in AGH YAML configuration file as a value to `password` setting:
users:
- name: "..."
password: <HASH>
### API: Log in
Perform a log-in operation for administrator. Server generates a session for this name+password pair, stores it in file. UI needs to perform all requests with this value inside Cookie HTTP header.
Request:
POST /control/login
{
name: "..."
password: "..."
}
Response:
200 OK
Set-Cookie: session=...; Expires=Wed, 09 Jun 2021 10:18:14 GMT; Path=/; HttpOnly
### API: Log out
Perform a log-out operation for administrator. Server removes the session from its DB and sets an expired cookie value.
Request:
GET /control/logout
Response:
302 Found
Location: /login.html
Set-Cookie: session=...; Expires=Thu, 01 Jan 1970 00:00:00 GMT

3
go.mod
View File

@ -18,7 +18,8 @@ require (
github.com/miekg/dns v1.1.8 github.com/miekg/dns v1.1.8
github.com/sparrc/go-ping v0.0.0-20181106165434-ef3ab45e41b0 github.com/sparrc/go-ping v0.0.0-20181106165434-ef3ab45e41b0
github.com/stretchr/testify v1.4.0 github.com/stretchr/testify v1.4.0
go.etcd.io/bbolt v1.3.3 // indirect go.etcd.io/bbolt v1.3.3
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5
golang.org/x/net v0.0.0-20190620200207-3b0461eec859 golang.org/x/net v0.0.0-20190620200207-3b0461eec859
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0 golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0
gopkg.in/yaml.v2 v2.2.2 gopkg.in/yaml.v2 v2.2.2

408
home/auth.go Normal file
View File

@ -0,0 +1,408 @@
package home
import (
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"math/rand"
"net/http"
"strings"
"sync"
"time"
"github.com/AdguardTeam/golibs/log"
"go.etcd.io/bbolt"
"golang.org/x/crypto/bcrypt"
)
const cookieTTL = 365 * 24 // in hours
const expireTime = 30 * 24 // in hours
// Auth - global object
type Auth struct {
db *bbolt.DB
sessions map[string]uint32 // session -> expiration time (in seconds)
lock sync.Mutex
users []User
}
// User object
type User struct {
Name string `yaml:"name"`
PasswordHash string `yaml:"password"` // bcrypt hash
}
// InitAuth - create a global object
func InitAuth(dbFilename string, users []User) *Auth {
a := Auth{}
a.sessions = make(map[string]uint32)
rand.Seed(time.Now().UTC().Unix())
var err error
a.db, err = bbolt.Open(dbFilename, 0644, nil)
if err != nil {
log.Error("Auth: bbolt.Open: %s", err)
return nil
}
a.loadSessions()
a.users = users
log.Debug("Auth: initialized. users:%d sessions:%d", len(a.users), len(a.sessions))
return &a
}
// Close - close module
func (a *Auth) Close() {
_ = a.db.Close()
}
// load sessions from file, remove expired sessions
func (a *Auth) loadSessions() {
tx, err := a.db.Begin(true)
if err != nil {
log.Error("Auth: bbolt.Begin: %s", err)
return
}
defer func() {
_ = tx.Rollback()
}()
bkt := tx.Bucket([]byte("sessions"))
if bkt == nil {
return
}
removed := 0
now := uint32(time.Now().UTC().Unix())
forEach := func(k, v []byte) error {
i := binary.BigEndian.Uint32(v)
if i <= now {
err = bkt.Delete(k)
if err != nil {
log.Error("Auth: bbolt.Delete: %s", err)
} else {
removed++
}
return nil
}
a.sessions[hex.EncodeToString(k)] = i
return nil
}
_ = bkt.ForEach(forEach)
if removed != 0 {
tx.Commit()
}
log.Debug("Auth: loaded %d sessions from DB (removed %d expired)", len(a.sessions), removed)
}
// store session data in file
func (a *Auth) storeSession(data []byte, expire uint32) {
a.lock.Lock()
a.sessions[hex.EncodeToString(data)] = expire
a.lock.Unlock()
tx, err := a.db.Begin(true)
if err != nil {
log.Error("Auth: bbolt.Begin: %s", err)
return
}
defer func() {
_ = tx.Rollback()
}()
bkt, err := tx.CreateBucketIfNotExists([]byte("sessions"))
if err != nil {
log.Error("Auth: bbolt.CreateBucketIfNotExists: %s", err)
return
}
var val []byte
val = make([]byte, 4)
binary.BigEndian.PutUint32(val, expire)
err = bkt.Put(data, val)
if err != nil {
log.Error("Auth: bbolt.Put: %s", err)
return
}
err = tx.Commit()
if err != nil {
log.Error("Auth: bbolt.Commit: %s", err)
return
}
log.Debug("Auth: stored session in DB")
}
// remove session from file
func (a *Auth) removeSession(sess []byte) {
tx, err := a.db.Begin(true)
if err != nil {
log.Error("Auth: bbolt.Begin: %s", err)
return
}
defer func() {
_ = tx.Rollback()
}()
bkt := tx.Bucket([]byte("sessions"))
if bkt == nil {
log.Error("Auth: bbolt.Bucket")
return
}
err = bkt.Delete(sess)
if err != nil {
log.Error("Auth: bbolt.Put: %s", err)
return
}
err = tx.Commit()
if err != nil {
log.Error("Auth: bbolt.Commit: %s", err)
return
}
log.Debug("Auth: removed session from DB")
}
// CheckSession - check if session is valid
// Return 0 if OK; -1 if session doesn't exist; 1 if session has expired
func (a *Auth) CheckSession(sess string) int {
now := uint32(time.Now().UTC().Unix())
update := false
a.lock.Lock()
expire, ok := a.sessions[sess]
if !ok {
a.lock.Unlock()
return -1
}
if expire <= now {
delete(a.sessions, sess)
key, _ := hex.DecodeString(sess)
a.removeSession(key)
a.lock.Unlock()
return 1
}
newExpire := now + expireTime*60*60
if expire/(24*60*60) != newExpire/(24*60*60) {
// update expiration time once a day
update = true
a.sessions[sess] = newExpire
}
a.lock.Unlock()
if update {
key, _ := hex.DecodeString(sess)
a.storeSession(key, expire)
}
return 0
}
// RemoveSession - remove session
func (a *Auth) RemoveSession(sess string) {
key, _ := hex.DecodeString(sess)
a.lock.Lock()
delete(a.sessions, sess)
a.lock.Unlock()
a.removeSession(key)
}
type loginJSON struct {
Name string `json:"name"`
Password string `json:"password"`
}
func getSession(u *User) []byte {
d := []byte(fmt.Sprintf("%d%s%s", rand.Uint32(), u.Name, u.PasswordHash))
hash := sha256.Sum256(d)
return hash[:]
}
func handleLogin(w http.ResponseWriter, r *http.Request) {
req := loginJSON{}
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
httpError(w, http.StatusBadRequest, "json decode: %s", err)
return
}
u := config.auth.UserFind(req.Name, req.Password)
if len(u.Name) == 0 {
time.Sleep(1 * time.Second)
httpError(w, http.StatusBadRequest, "invalid login or password")
return
}
sess := getSession(&u)
now := time.Now().UTC()
expire := now.Add(cookieTTL * time.Hour)
expstr := expire.Format(time.RFC1123)
expstr = expstr[:len(expstr)-len("UTC")] // "UTC" -> "GMT"
expstr += "GMT"
expireSess := uint32(now.Unix()) + expireTime*60*60
config.auth.storeSession(sess, expireSess)
s := fmt.Sprintf("session=%s; Path=/; HttpOnly; Expires=%s", hex.EncodeToString(sess), expstr)
w.Header().Set("Set-Cookie", s)
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
returnOK(w)
}
func handleLogout(w http.ResponseWriter, r *http.Request) {
cookie := r.Header.Get("Cookie")
sess := parseCookie(cookie)
config.auth.RemoveSession(sess)
w.Header().Set("Location", "/login.html")
s := fmt.Sprintf("session=; Path=/; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:00 GMT")
w.Header().Set("Set-Cookie", s)
w.WriteHeader(http.StatusFound)
}
// RegisterAuthHandlers - register handlers
func RegisterAuthHandlers() {
http.Handle("/control/login", postInstallHandler(ensureHandler("POST", handleLogin)))
httpRegister("GET", "/control/logout", handleLogout)
}
func parseCookie(cookie string) string {
pairs := strings.Split(cookie, ";")
for _, pair := range pairs {
pair = strings.TrimSpace(pair)
kv := strings.SplitN(pair, "=", 2)
if len(kv) != 2 {
continue
}
if kv[0] == "session" {
return kv[1]
}
}
return ""
}
func optionalAuth(handler func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/login.html" {
// redirect to dashboard if already authenticated
authRequired := config.auth != nil && config.auth.AuthRequired()
cookie, err := r.Cookie("session")
if authRequired && err == nil {
r := config.auth.CheckSession(cookie.Value)
if r == 0 {
w.Header().Set("Location", "/")
w.WriteHeader(http.StatusFound)
return
} else if r < 0 {
log.Debug("Auth: invalid cookie value: %s", cookie)
}
}
} else if r.URL.Path == "/favicon.png" ||
strings.HasPrefix(r.URL.Path, "/login.") {
// process as usual
} else if config.auth != nil && config.auth.AuthRequired() {
// redirect to login page if not authenticated
ok := false
cookie, err := r.Cookie("session")
if err == nil {
r := config.auth.CheckSession(cookie.Value)
if r == 0 {
ok = true
} else if r < 0 {
log.Debug("Auth: invalid cookie value: %s", cookie)
}
} else {
// there's no Cookie, check Basic authentication
user, pass, ok2 := r.BasicAuth()
if ok2 {
u := config.auth.UserFind(user, pass)
if len(u.Name) != 0 {
ok = true
}
}
}
if !ok {
w.Header().Set("Location", "/login.html")
w.WriteHeader(http.StatusFound)
return
}
}
handler(w, r)
}
}
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}
}
// UserAdd - add new user
func (a *Auth) UserAdd(u *User, password string) {
if len(password) == 0 {
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
log.Error("bcrypt.GenerateFromPassword: %s", err)
return
}
u.PasswordHash = string(hash)
a.lock.Lock()
a.users = append(a.users, *u)
a.lock.Unlock()
log.Debug("Auth: added user: %s", u.Name)
}
// UserFind - find a user
func (a *Auth) UserFind(login string, password string) User {
a.lock.Lock()
defer a.lock.Unlock()
for _, u := range a.users {
if u.Name == login &&
bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil {
return u
}
}
return User{}
}
// GetUsers - get users
func (a *Auth) GetUsers() []User {
a.lock.Lock()
users := a.users
a.lock.Unlock()
return users
}
// AuthRequired - if authentication is required
func (a *Auth) AuthRequired() bool {
a.lock.Lock()
r := (len(a.users) != 0)
a.lock.Unlock()
return r
}

56
home/auth_test.go Normal file
View File

@ -0,0 +1,56 @@
package home
import (
"encoding/hex"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestAuth(t *testing.T) {
fn := "./sessions.db"
users := []User{
User{Name: "name", PasswordHash: "$2y$05$..vyzAECIhJPfaQiOK17IukcQnqEgKJHy0iETyYqxn3YXJl8yZuo2"},
}
os.Remove(fn)
config.ourWorkingDir = "."
a := InitAuth(fn, users)
assert.True(t, a.CheckSession("notfound") == -1)
a.RemoveSession("notfound")
sess := getSession(&users[0])
sessStr := hex.EncodeToString(sess)
// check expiration
a.storeSession(sess, uint32(time.Now().UTC().Unix()))
assert.True(t, a.CheckSession(sessStr) == 1)
// add session with TTL = 2 sec
a.storeSession(sess, uint32(time.Now().UTC().Unix()+2))
assert.True(t, a.CheckSession(sessStr) == 0)
a.Close()
// load saved session
a = InitAuth(fn, users)
// the session is still alive
assert.True(t, a.CheckSession(sessStr) == 0)
a.Close()
u := a.UserFind("name", "password")
assert.True(t, len(u.Name) != 0)
time.Sleep(3 * time.Second)
// load and remove expired sessions
a = InitAuth(fn, users)
assert.True(t, a.CheckSession(sessStr) == -1)
a.Close()
os.Remove(fn)
}

View File

@ -65,13 +65,14 @@ type configuration struct {
runningAsService bool runningAsService bool
disableUpdate bool // If set, don't check for updates disableUpdate bool // If set, don't check for updates
appSignalChannel chan os.Signal appSignalChannel chan os.Signal
clients clientsContainer clients clientsContainer // per-client-settings module
controlLock sync.Mutex controlLock sync.Mutex
transport *http.Transport transport *http.Transport
client *http.Client client *http.Client
stats stats.Stats stats stats.Stats // statistics module
queryLog querylog.QueryLog queryLog querylog.QueryLog // query log module
filteringStarted bool filteringStarted bool // TRUE if filtering module is started
auth *Auth // HTTP authentication module
// cached version.json to avoid hammering github.io for each page reload // cached version.json to avoid hammering github.io for each page reload
versionCheckJSON []byte versionCheckJSON []byte
@ -85,8 +86,7 @@ type configuration struct {
BindHost string `yaml:"bind_host"` // BindHost is the IP address of the HTTP server to bind to BindHost string `yaml:"bind_host"` // BindHost is the IP address of the HTTP server to bind to
BindPort int `yaml:"bind_port"` // BindPort is the port the HTTP server BindPort int `yaml:"bind_port"` // BindPort is the port the HTTP server
AuthName string `yaml:"auth_name"` // AuthName is the basic auth username Users []User `yaml:"users"` // Users that can access HTTP server
AuthPass string `yaml:"auth_pass"` // AuthPass is the basic auth password
Language string `yaml:"language"` // two-letter ISO 639-1 language code Language string `yaml:"language"` // two-letter ISO 639-1 language code
RlimitNoFile uint `yaml:"rlimit_nofile"` // Maximum number of opened fd's per process (0: default) RlimitNoFile uint `yaml:"rlimit_nofile"` // Maximum number of opened fd's per process (0: default)
@ -352,6 +352,10 @@ func (c *configuration) write() error {
config.Clients = append(config.Clients, cy) config.Clients = append(config.Clients, cy)
} }
if config.auth != nil {
config.Users = config.auth.GetUsers()
}
configFile := config.getConfigFilename() configFile := config.getConfigFilename()
log.Debug("Writing YAML file: %s", configFile) log.Debug("Writing YAML file: %s", configFile)
yamlText, err := yaml.Marshal(&config) yamlText, err := yaml.Marshal(&config)

View File

@ -570,6 +570,7 @@ func registerControlHandlers() {
RegisterBlockedServicesHandlers() RegisterBlockedServicesHandlers()
RegisterQueryLogHandlers() RegisterQueryLogHandlers()
RegisterStatsHandlers() RegisterStatsHandlers()
RegisterAuthHandlers()
http.HandleFunc("/dns-query", postInstall(handleDOH)) http.HandleFunc("/dns-query", postInstall(handleDOH))
} }

View File

@ -183,8 +183,6 @@ func copyInstallSettings(dst *configuration, src *configuration) {
dst.BindPort = src.BindPort dst.BindPort = src.BindPort
dst.DNS.BindHost = src.DNS.BindHost dst.DNS.BindHost = src.DNS.BindHost
dst.DNS.Port = src.DNS.Port dst.DNS.Port = src.DNS.Port
dst.AuthName = src.AuthName
dst.AuthPass = src.AuthPass
} }
// Apply new configuration, start DNS server, restart Web server // Apply new configuration, start DNS server, restart Web server
@ -237,8 +235,6 @@ func handleInstallConfigure(w http.ResponseWriter, r *http.Request) {
config.BindPort = newSettings.Web.Port config.BindPort = newSettings.Web.Port
config.DNS.BindHost = newSettings.DNS.IP config.DNS.BindHost = newSettings.DNS.IP
config.DNS.Port = newSettings.DNS.Port config.DNS.Port = newSettings.DNS.Port
config.AuthName = newSettings.Username
config.AuthPass = newSettings.Password
dnsBaseDir := filepath.Join(config.ourWorkingDir, dataDir) dnsBaseDir := filepath.Join(config.ourWorkingDir, dataDir)
initDNSServer(dnsBaseDir) initDNSServer(dnsBaseDir)
@ -251,6 +247,10 @@ func handleInstallConfigure(w http.ResponseWriter, r *http.Request) {
return return
} }
u := User{}
u.Name = newSettings.Username
config.auth.UserAdd(&u, newSettings.Password)
err = config.write() err = config.write()
if err != nil { if err != nil {
config.firstRun = true config.firstRun = true

View File

@ -51,6 +51,10 @@ func initDNSServer(baseDir string) {
config.queryLog = querylog.New(conf) config.queryLog = querylog.New(conf)
config.dnsServer = dnsforward.NewServer(config.stats, config.queryLog) config.dnsServer = dnsforward.NewServer(config.stats, config.queryLog)
sessFilename := filepath.Join(config.ourWorkingDir, "data/sessions.db")
config.auth = InitAuth(sessFilename, config.Users)
config.Users = nil
initRDNS() initRDNS()
initFiltering() initFiltering()
} }
@ -202,6 +206,6 @@ func stopDNSServer() error {
config.stats.Close() config.stats.Close()
config.queryLog.Close() config.queryLog.Close()
config.auth.Close()
return nil return nil
} }

View File

@ -68,35 +68,6 @@ func ensureHandler(method string, handler func(http.ResponseWriter, *http.Reques
return &h return &h
} }
func optionalAuth(handler func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
if config.AuthName == "" || config.AuthPass == "" {
handler(w, r)
return
}
user, pass, ok := r.BasicAuth()
if !ok || user != config.AuthName || pass != config.AuthPass {
w.Header().Set("WWW-Authenticate", `Basic realm="dnsfilter"`)
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Unauthorised.\n"))
return
}
handler(w, r)
}
}
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}
}
// ------------------- // -------------------
// first run / install // first run / install
// ------------------- // -------------------

View File

@ -11,3 +11,12 @@ The easiest way would be to use [Swagger Editor](http://editor.swagger.io/) and
1. `yarn install` 1. `yarn install`
2. `yarn start` 2. `yarn start`
3. Open `http://localhost:4000/` 3. Open `http://localhost:4000/`
### Authentication
If AdGuard Home's web user is password-protected, a web client must use authentication mechanism when sending requests to server. Basic access authentication is the most simple method - a client must pass `Authorization` HTTP header along with all requests:
Authorization: Basic BASE64_DATA
where BASE64_DATA is base64-encoded data for `username:password` string.