From 6304a7b91bfbfeeaffae1d8fcec3584ada0cc0be Mon Sep 17 00:00:00 2001
From: Simon Zolin <s.zolin@adguard.com>
Date: Thu, 29 Aug 2019 12:34:07 +0300
Subject: [PATCH 1/9] + Login page and web sessions

+ /control/login
+ /control/logout
---
 AGHTechDoc.md           |  82 ++++++++
 go.mod                  |   3 +-
 home/auth.go            | 408 ++++++++++++++++++++++++++++++++++++++++
 home/auth_test.go       |  56 ++++++
 home/config.go          |  16 +-
 home/control.go         |   1 +
 home/control_install.go |   8 +-
 home/dns.go             |   6 +-
 home/helpers.go         |  29 ---
 openapi/README.md       |   9 +
 10 files changed, 577 insertions(+), 41 deletions(-)
 create mode 100644 home/auth.go
 create mode 100644 home/auth_test.go

diff --git a/AGHTechDoc.md b/AGHTechDoc.md
index c2206449..0522439e 100644
--- a/AGHTechDoc.md
+++ b/AGHTechDoc.md
@@ -50,6 +50,9 @@ Contents:
 	* API: Get filtering parameters
 	* API: Set filtering parameters
 	* API: Set URL parameters
+* Log-in page
+	* API: Log in
+	* API: Log out
 
 
 ## Relations between subsystems
@@ -1097,3 +1100,82 @@ Request:
 Response:
 
 	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
diff --git a/go.mod b/go.mod
index 66751c7f..f15b6e19 100644
--- a/go.mod
+++ b/go.mod
@@ -18,7 +18,8 @@ require (
 	github.com/miekg/dns v1.1.8
 	github.com/sparrc/go-ping v0.0.0-20181106165434-ef3ab45e41b0
 	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/sys v0.0.0-20190624142023-c5567b49c5d0
 	gopkg.in/yaml.v2 v2.2.2
diff --git a/home/auth.go b/home/auth.go
new file mode 100644
index 00000000..b4c39d10
--- /dev/null
+++ b/home/auth.go
@@ -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
+}
diff --git a/home/auth_test.go b/home/auth_test.go
new file mode 100644
index 00000000..61bf4b23
--- /dev/null
+++ b/home/auth_test.go
@@ -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)
+}
diff --git a/home/config.go b/home/config.go
index 9062d148..fd8bbed4 100644
--- a/home/config.go
+++ b/home/config.go
@@ -65,13 +65,14 @@ type configuration struct {
 	runningAsService bool
 	disableUpdate    bool // If set, don't check for updates
 	appSignalChannel chan os.Signal
-	clients          clientsContainer
+	clients          clientsContainer // per-client-settings module
 	controlLock      sync.Mutex
 	transport        *http.Transport
 	client           *http.Client
-	stats            stats.Stats
-	queryLog         querylog.QueryLog
-	filteringStarted bool
+	stats            stats.Stats       // statistics module
+	queryLog         querylog.QueryLog // query log module
+	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
 	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
 	BindPort     int    `yaml:"bind_port"`     // BindPort is the port the HTTP server
-	AuthName     string `yaml:"auth_name"`     // AuthName is the basic auth username
-	AuthPass     string `yaml:"auth_pass"`     // AuthPass is the basic auth password
+	Users        []User `yaml:"users"`         // Users that can access HTTP server
 	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)
 
@@ -352,6 +352,10 @@ func (c *configuration) write() error {
 		config.Clients = append(config.Clients, cy)
 	}
 
+	if config.auth != nil {
+		config.Users = config.auth.GetUsers()
+	}
+
 	configFile := config.getConfigFilename()
 	log.Debug("Writing YAML file: %s", configFile)
 	yamlText, err := yaml.Marshal(&config)
diff --git a/home/control.go b/home/control.go
index 8fb2d15f..f6cded37 100644
--- a/home/control.go
+++ b/home/control.go
@@ -570,6 +570,7 @@ func registerControlHandlers() {
 	RegisterBlockedServicesHandlers()
 	RegisterQueryLogHandlers()
 	RegisterStatsHandlers()
+	RegisterAuthHandlers()
 
 	http.HandleFunc("/dns-query", postInstall(handleDOH))
 }
diff --git a/home/control_install.go b/home/control_install.go
index 9eb47bfb..822f0c0b 100644
--- a/home/control_install.go
+++ b/home/control_install.go
@@ -183,8 +183,6 @@ func copyInstallSettings(dst *configuration, src *configuration) {
 	dst.BindPort = src.BindPort
 	dst.DNS.BindHost = src.DNS.BindHost
 	dst.DNS.Port = src.DNS.Port
-	dst.AuthName = src.AuthName
-	dst.AuthPass = src.AuthPass
 }
 
 // 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.DNS.BindHost = newSettings.DNS.IP
 	config.DNS.Port = newSettings.DNS.Port
-	config.AuthName = newSettings.Username
-	config.AuthPass = newSettings.Password
 
 	dnsBaseDir := filepath.Join(config.ourWorkingDir, dataDir)
 	initDNSServer(dnsBaseDir)
@@ -251,6 +247,10 @@ func handleInstallConfigure(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	u := User{}
+	u.Name = newSettings.Username
+	config.auth.UserAdd(&u, newSettings.Password)
+
 	err = config.write()
 	if err != nil {
 		config.firstRun = true
diff --git a/home/dns.go b/home/dns.go
index 259ad5cb..c775bb4a 100644
--- a/home/dns.go
+++ b/home/dns.go
@@ -51,6 +51,10 @@ func initDNSServer(baseDir string) {
 	config.queryLog = querylog.New(conf)
 	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()
 	initFiltering()
 }
@@ -202,6 +206,6 @@ func stopDNSServer() error {
 
 	config.stats.Close()
 	config.queryLog.Close()
-
+	config.auth.Close()
 	return nil
 }
diff --git a/home/helpers.go b/home/helpers.go
index 23744fc0..c71aa5ff 100644
--- a/home/helpers.go
+++ b/home/helpers.go
@@ -68,35 +68,6 @@ func ensureHandler(method string, handler func(http.ResponseWriter, *http.Reques
 	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
 // -------------------
diff --git a/openapi/README.md b/openapi/README.md
index cd689408..48dacd1e 100644
--- a/openapi/README.md
+++ b/openapi/README.md
@@ -11,3 +11,12 @@ The easiest way would be to use [Swagger Editor](http://editor.swagger.io/) and
 1. `yarn install`
 2. `yarn start`
 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.

From d2a5a550f746a0c2436db2485921e36c3269049f Mon Sep 17 00:00:00 2001
From: Simon Zolin <s.zolin@adguard.com>
Date: Wed, 11 Sep 2019 19:26:37 +0300
Subject: [PATCH 2/9] + openapi: /login, /logout

---
 openapi/openapi.yaml | 40 +++++++++++++++++++++++++++++++++++++++-
 1 file changed, 39 insertions(+), 1 deletion(-)

diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml
index fa36fac9..8febe107 100644
--- a/openapi/openapi.yaml
+++ b/openapi/openapi.yaml
@@ -932,6 +932,34 @@ paths:
                 500:
                     description: "Cannot start the DNS server"
 
+    /login:
+        post:
+            tags:
+                - global
+            operationId: login
+            summary: "Perform administrator log-in"
+            consumes:
+            - application/json
+            parameters:
+            - in: "body"
+              name: "body"
+              required: true
+              schema:
+                $ref: "#/definitions/Login"
+            responses:
+                200:
+                    description: OK
+
+    /logout:
+        get:
+            tags:
+                - global
+            operationId: logout
+            summary: "Perform administrator log-out"
+            responses:
+                302:
+                    description: OK
+
 definitions:
     ServerStatus:
         type: "object"
@@ -1673,4 +1701,14 @@ definitions:
             password:
                 type: "string"
                 description: "Basic auth password"
-                example: "password"
\ No newline at end of file
+                example: "password"
+    Login:
+        type: "object"
+        description: "Login request data"
+        properties:
+            username:
+                type: "string"
+                description: "User name"
+            password:
+                type: "string"
+                description: "Password"

From a71521a658843854c49c931a0acdceaf85fb45dc Mon Sep 17 00:00:00 2001
From: Simon Zolin <s.zolin@adguard.com>
Date: Fri, 13 Sep 2019 14:19:10 +0300
Subject: [PATCH 3/9] + config: upgrade from v4 to v5

---
 home/upgrade.go | 57 ++++++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 56 insertions(+), 1 deletion(-)

diff --git a/home/upgrade.go b/home/upgrade.go
index 491e5a68..9445e8b1 100644
--- a/home/upgrade.go
+++ b/home/upgrade.go
@@ -7,10 +7,11 @@ import (
 
 	"github.com/AdguardTeam/golibs/file"
 	"github.com/AdguardTeam/golibs/log"
+	"golang.org/x/crypto/bcrypt"
 	yaml "gopkg.in/yaml.v2"
 )
 
-const currentSchemaVersion = 4 // used for upgrading from old configs to new config
+const currentSchemaVersion = 5 // used for upgrading from old configs to new config
 
 // Performs necessary upgrade operations if needed
 func upgradeConfig() error {
@@ -75,6 +76,12 @@ func upgradeConfigSchema(oldVersion int, diskConfig *map[string]interface{}) err
 		if err != nil {
 			return err
 		}
+		fallthrough
+	case 4:
+		err := upgradeSchema4to5(diskConfig)
+		if err != nil {
+			return err
+		}
 	default:
 		err := fmt.Errorf("configuration file contains unknown schema_version, abort")
 		log.Println(err)
@@ -213,3 +220,51 @@ func upgradeSchema3to4(diskConfig *map[string]interface{}) error {
 
 	return nil
 }
+
+// Replace "auth_name", "auth_pass" string settings with an array:
+// users:
+// - name: "..."
+//   password: "..."
+// ...
+func upgradeSchema4to5(diskConfig *map[string]interface{}) error {
+	log.Printf("%s(): called", _Func())
+
+	(*diskConfig)["schema_version"] = 5
+
+	name, ok := (*diskConfig)["auth_name"]
+	if !ok {
+		return nil
+	}
+	nameStr, ok := name.(string)
+	if !ok {
+		log.Fatal("Please use double quotes in your user name in \"auth_name\" and restart AdGuardHome")
+		return nil
+	}
+
+	pass, ok := (*diskConfig)["auth_pass"]
+	if !ok {
+		return nil
+	}
+	passStr, ok := pass.(string)
+	if !ok {
+		log.Fatal("Please use double quotes in your password in \"auth_pass\" and restart AdGuardHome")
+		return nil
+	}
+
+	if len(nameStr) == 0 {
+		return nil
+	}
+
+	hash, err := bcrypt.GenerateFromPassword([]byte(passStr), bcrypt.DefaultCost)
+	if err != nil {
+		log.Fatalf("Can't use password \"%s\": bcrypt.GenerateFromPassword: %s", passStr, err)
+		return nil
+	}
+	u := User{
+		Name:         nameStr,
+		PasswordHash: string(hash),
+	}
+	users := []User{u}
+	(*diskConfig)["users"] = users
+	return nil
+}

From d7f256ba7f1a61e7806621bbef950a251706f593 Mon Sep 17 00:00:00 2001
From: Simon Zolin <s.zolin@adguard.com>
Date: Mon, 16 Sep 2019 15:54:41 +0300
Subject: [PATCH 4/9] - fix crash after stats module is closed

Close DNS forward module BEFORE stats.
---
 dnsforward/dnsforward.go | 11 +++++++++++
 home/dns.go              |  8 ++++++++
 stats/stats_unit.go      |  1 +
 3 files changed, 20 insertions(+)

diff --git a/dnsforward/dnsforward.go b/dnsforward/dnsforward.go
index 59b2a6d3..862bf162 100644
--- a/dnsforward/dnsforward.go
+++ b/dnsforward/dnsforward.go
@@ -63,6 +63,13 @@ func NewServer(stats stats.Stats, queryLog querylog.QueryLog) *Server {
 	return s
 }
 
+func (s *Server) Close() {
+	s.Lock()
+	s.stats = nil
+	s.queryLog = nil
+	s.Unlock()
+}
+
 // FilteringConfig represents the DNS filtering configuration of AdGuard Home
 // The zero FilteringConfig is empty and ready for use.
 type FilteringConfig struct {
@@ -467,6 +474,9 @@ func (s *Server) handleDNSRequest(p *proxy.Proxy, d *proxy.DNSContext) error {
 	}
 
 	elapsed := time.Since(start)
+	s.RLock()
+	// Synchronize access to s.queryLog and s.stats so they won't be suddenly uninitialized while in use.
+	// This can happen after proxy server has been stopped, but its workers haven't yet exited.
 	if s.conf.QueryLogEnabled && shouldLog && s.queryLog != nil {
 		upstreamAddr := ""
 		if d.Upstream != nil {
@@ -476,6 +486,7 @@ func (s *Server) handleDNSRequest(p *proxy.Proxy, d *proxy.DNSContext) error {
 	}
 
 	s.updateStats(d, elapsed, *res)
+	s.RUnlock()
 
 	return nil
 }
diff --git a/home/dns.go b/home/dns.go
index c775bb4a..c9f199e1 100644
--- a/home/dns.go
+++ b/home/dns.go
@@ -204,8 +204,16 @@ func stopDNSServer() error {
 		return errorx.Decorate(err, "Couldn't stop forwarding DNS server")
 	}
 
+	// DNS forward module must be closed BEFORE stats or queryLog because it depends on them
+	config.dnsServer.Close()
+
 	config.stats.Close()
+	config.stats = nil
+
 	config.queryLog.Close()
+	config.queryLog = nil
+
 	config.auth.Close()
+	config.auth = nil
 	return nil
 }
diff --git a/stats/stats_unit.go b/stats/stats_unit.go
index 20bfb721..eb6d6e85 100644
--- a/stats/stats_unit.go
+++ b/stats/stats_unit.go
@@ -420,6 +420,7 @@ func (s *statsCtx) Clear() {
 
 func (s *statsCtx) Update(e Entry) {
 	if e.Result == 0 ||
+		e.Result >= rLast ||
 		len(e.Domain) == 0 ||
 		!(len(e.Client) == 4 || len(e.Client) == 16) {
 		return

From 4e76013334b1b5e835df5671b10c0ccc2ebc60b6 Mon Sep 17 00:00:00 2001
From: Andrey Meshkov <am@adguard.com>
Date: Wed, 18 Sep 2019 00:26:58 +0300
Subject: [PATCH 5/9] *(home): fix golint issues

---
 home/auth.go             |  3 ++-
 home/blocked_services.go | 16 +++++++++++++++-
 home/clients.go          |  2 +-
 3 files changed, 18 insertions(+), 3 deletions(-)

diff --git a/home/auth.go b/home/auth.go
index b4c39d10..1e9c3b90 100644
--- a/home/auth.go
+++ b/home/auth.go
@@ -90,7 +90,7 @@ func (a *Auth) loadSessions() {
 	}
 	_ = bkt.ForEach(forEach)
 	if removed != 0 {
-		tx.Commit()
+		_ = tx.Commit()
 	}
 	log.Debug("Auth: loaded %d sessions from DB (removed %d expired)", len(a.sessions), removed)
 }
@@ -321,6 +321,7 @@ func optionalAuth(handler func(http.ResponseWriter, *http.Request)) func(http.Re
 			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)
diff --git a/home/blocked_services.go b/home/blocked_services.go
index c3a6dc26..f9f20135 100644
--- a/home/blocked_services.go
+++ b/home/blocked_services.go
@@ -35,7 +35,21 @@ var serviceRulesArray = []svc{
 	{"vk", []string{"||vk.com^"}},
 	{"steam", []string{"||steam.com^"}},
 	{"mail_ru", []string{"||mail.ru^"}},
-	{"tiktok", []string{"||tiktok.com^", "||snssdk.com^", "||amemv.com^", "||toutiao.com^", "||ixigua.com^", "||pstatp.com^", "||ixiguavideo.com^", "||toutiaocloud.com^", "||toutiaocloud.net^", "||bdurl.com^", "||bytecdn.cn^", "||byteimg.com^", "||ixigua.com^"}},
+	{"tiktok", []string{
+		"||tiktok.com^",
+		"||snssdk.com^",
+		"||amemv.com^",
+		"||toutiao.com^",
+		"||ixigua.com^",
+		"||pstatp.com^",
+		"||ixiguavideo.com^",
+		"||toutiaocloud.com^",
+		"||toutiaocloud.net^",
+		"||bdurl.com^",
+		"||bytecdn.cn^",
+		"||byteimg.com^",
+		"||ixigua.com^",
+	}},
 }
 
 // convert array to map
diff --git a/home/clients.go b/home/clients.go
index 2289734c..e80a7a00 100644
--- a/home/clients.go
+++ b/home/clients.go
@@ -378,7 +378,7 @@ func (clients *clientsContainer) addFromDHCP() {
 		if len(l.Hostname) == 0 {
 			continue
 		}
-		config.clients.AddHost(l.IP.String(), l.Hostname, ClientSourceDHCP)
+		_, _ = config.clients.AddHost(l.IP.String(), l.Hostname, ClientSourceDHCP)
 	}
 }
 

From 07fa9bb47c0cb9cb40d542cfa690b1318cb32c16 Mon Sep 17 00:00:00 2001
From: Simon Zolin <s.zolin@adguard.com>
Date: Wed, 18 Sep 2019 13:17:35 +0300
Subject: [PATCH 6/9] * minor

---
 home/auth.go | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/home/auth.go b/home/auth.go
index 1e9c3b90..21b74adc 100644
--- a/home/auth.go
+++ b/home/auth.go
@@ -90,7 +90,10 @@ func (a *Auth) loadSessions() {
 	}
 	_ = bkt.ForEach(forEach)
 	if removed != 0 {
-		_ = tx.Commit()
+		err = tx.Commit()
+		if err != nil {
+			log.Error("bolt.Commit(): %s", err)
+		}
 	}
 	log.Debug("Auth: loaded %d sessions from DB (removed %d expired)", len(a.sessions), removed)
 }

From 1e4edf06695e4e607ccf39c1f26df71e69e2ce34 Mon Sep 17 00:00:00 2001
From: Ildar Kamalov <i.kamalov@adguard.com>
Date: Fri, 6 Sep 2019 15:48:33 +0300
Subject: [PATCH 7/9] * client: fix translation string

---
 client/src/__locales/en.json | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/client/src/__locales/en.json b/client/src/__locales/en.json
index 9c59c654..d472dc50 100644
--- a/client/src/__locales/en.json
+++ b/client/src/__locales/en.json
@@ -199,7 +199,7 @@
     "install_settings_dns_desc": "You will need to configure your devices or router to use the DNS server on the following addresses:",
     "install_settings_all_interfaces": "All interfaces",
     "install_auth_title": "Authentication",
-    "install_auth_desc": "It is highly recommended to configure password authentication to your AdGuard Home admin web interface. Even if it is accessible only in your local network, it is still important to have it protected from unrestricted access.",
+    "install_auth_desc": "It is highly recommended to configure password authentication to your AdGuard Home admin web interface. Even if it is accessible only in your local network, it is still important to to protect it from unrestricted access.",
     "install_auth_username": "Username",
     "install_auth_password": "Password",
     "install_auth_confirm": "Confirm password",

From 66bd06cf69b7482251a7d4b0321f029546a8decd Mon Sep 17 00:00:00 2001
From: Ildar Kamalov <i.kamalov@adguard.com>
Date: Thu, 5 Sep 2019 19:07:14 +0300
Subject: [PATCH 8/9] + client: login page

---
 client/public/login.html                      |  17 +++
 client/src/__locales/en.json                  |  12 +-
 client/src/__locales/ru.json                  |   9 +-
 client/src/actions/login.js                   |  20 +++
 client/src/api/Api.js                         |  12 ++
 client/src/components/App/index.js            |  10 +-
 client/src/components/Header/Header.css       | 117 +++++++++++-------
 client/src/components/Header/Menu.js          |   2 +-
 client/src/components/Header/index.js         |  32 ++---
 client/src/components/ui/Footer.css           |  20 ++-
 client/src/components/ui/Footer.js            | 114 ++++++++++++-----
 client/src/components/ui/Icons.js             |   4 +
 client/src/components/ui/PageTitle.css        |  12 +-
 client/src/components/ui/Tabler.css           |   2 +-
 client/src/components/ui/Version.css          |  40 ++++++
 .../src/components/{Header => ui}/Version.js  |  22 +---
 client/src/containers/Header.js               |   8 +-
 client/src/helpers/form.js                    |   2 +
 client/src/install/Setup/Setup.css            |   2 +-
 client/src/login/Login/Form.js                |  75 +++++++++++
 client/src/login/Login/Login.css              |  47 +++++++
 client/src/login/Login/index.js               |  90 ++++++++++++++
 client/src/login/index.js                     |  18 +++
 client/src/reducers/index.js                  |   4 +-
 client/src/reducers/login.js                  |  24 ++++
 client/webpack.common.js                      |  10 ++
 26 files changed, 607 insertions(+), 118 deletions(-)
 create mode 100644 client/public/login.html
 create mode 100644 client/src/actions/login.js
 create mode 100644 client/src/components/ui/Version.css
 rename client/src/components/{Header => ui}/Version.js (53%)
 create mode 100644 client/src/login/Login/Form.js
 create mode 100644 client/src/login/Login/Login.css
 create mode 100644 client/src/login/Login/index.js
 create mode 100644 client/src/login/index.js
 create mode 100644 client/src/reducers/login.js

diff --git a/client/public/login.html b/client/public/login.html
new file mode 100644
index 00000000..03179b42
--- /dev/null
+++ b/client/public/login.html
@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html lang="en">
+    <head>
+        <meta charset="utf-8">
+        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+        <meta name="theme-color" content="#000000">
+        <meta name="google" content="notranslate">
+        <link rel="icon" type="image/png" href="favicon.png" sizes="48x48">
+        <title>Login</title>
+    </head>
+    <body>
+        <noscript>
+            You need to enable JavaScript to run this app.
+        </noscript>
+        <div id="root"></div>
+    </body>
+</html>
diff --git a/client/src/__locales/en.json b/client/src/__locales/en.json
index d472dc50..a978b315 100644
--- a/client/src/__locales/en.json
+++ b/client/src/__locales/en.json
@@ -199,7 +199,7 @@
     "install_settings_dns_desc": "You will need to configure your devices or router to use the DNS server on the following addresses:",
     "install_settings_all_interfaces": "All interfaces",
     "install_auth_title": "Authentication",
-    "install_auth_desc": "It is highly recommended to configure password authentication to your AdGuard Home admin web interface. Even if it is accessible only in your local network, it is still important to to protect it from unrestricted access.",
+    "install_auth_desc": "It is highly recommended to configure password authentication to your AdGuard Home admin web interface. Even if it is accessible only in your local network, it is still important to protect it from unrestricted access.",
     "install_auth_username": "Username",
     "install_auth_password": "Password",
     "install_auth_confirm": "Confirm password",
@@ -384,5 +384,13 @@
     "filters_configuration": "Filters configuration",
     "filters_enable": "Enable filters",
     "filters_interval": "Filters update interval",
-    "disabled": "Disabled"
+    "disabled": "Disabled",
+    "username_label": "Username",
+    "username_placeholder": "Enter username",
+    "password_label": "Password",
+    "password_placeholder": "Enter password",
+    "sign_in": "Sign in",
+    "logout": "Logout",
+    "forgot_password": "Forgot password?",
+    "forgot_password_desc": "Please follow <0>these steps</0> to create a new password for your user account."
 }
diff --git a/client/src/__locales/ru.json b/client/src/__locales/ru.json
index 88799f70..322d05f0 100644
--- a/client/src/__locales/ru.json
+++ b/client/src/__locales/ru.json
@@ -352,10 +352,17 @@
     "unblock_all": "Разблокировать все",
     "domain": "Домен",
     "answer": "Ответ",
+    "interval_24_hour": "24 часа",
     "interval_hours_0": "{{count}} час",
     "interval_hours_1": "{{count}} часа",
     "interval_hours_2": "{{count}} часов",
     "interval_days_0": "{{count}} день",
     "interval_days_1": "{{count}} дня",
-    "interval_days_2": "{{count}} дней"
+    "interval_days_2": "{{count}} дней",
+    "for_last_days_0": "за последний {{count}} день",
+    "for_last_days_1": "за последние {{count}} дня",
+    "for_last_days_2": "за последние {{count}} дней",
+    "number_of_dns_query_days_0": "Количество DNS-запросов за {{count}} день",
+    "number_of_dns_query_days_1": "Количество DNS-запросов за {{count}} дня",
+    "number_of_dns_query_days_2": "Количество DNS-запросов за {{count}} дней"
 }
\ No newline at end of file
diff --git a/client/src/actions/login.js b/client/src/actions/login.js
new file mode 100644
index 00000000..90cc0780
--- /dev/null
+++ b/client/src/actions/login.js
@@ -0,0 +1,20 @@
+import { createAction } from 'redux-actions';
+
+import { addErrorToast } from './index';
+import apiClient from '../api/Api';
+
+export const processLoginRequest = createAction('PROCESS_LOGIN_REQUEST');
+export const processLoginFailure = createAction('PROCESS_LOGIN_FAILURE');
+export const processLoginSuccess = createAction('PROCESS_LOGIN_SUCCESS');
+
+export const processLogin = values => async (dispatch) => {
+    dispatch(processLoginRequest());
+    try {
+        await apiClient.login(values);
+        window.location.replace(window.location.origin);
+        dispatch(processLoginSuccess());
+    } catch (error) {
+        dispatch(addErrorToast({ error }));
+        dispatch(processLoginFailure());
+    }
+};
diff --git a/client/src/api/Api.js b/client/src/api/Api.js
index 187b7312..b7a7d045 100644
--- a/client/src/api/Api.js
+++ b/client/src/api/Api.js
@@ -510,6 +510,18 @@ class Api {
         const { path, method } = this.QUERY_LOG_CLEAR;
         return this.makeRequest(path, method);
     }
+
+    // Login
+    LOGIN = { path: 'login', method: 'POST' };
+
+    login(data) {
+        const { path, method } = this.LOGIN;
+        const config = {
+            data,
+            headers: { 'Content-Type': 'application/json' },
+        };
+        return this.makeRequest(path, method, config);
+    }
 }
 
 const apiClient = new Api();
diff --git a/client/src/components/App/index.js b/client/src/components/App/index.js
index 6489649c..3fd4d1a5 100644
--- a/client/src/components/App/index.js
+++ b/client/src/components/App/index.js
@@ -64,7 +64,7 @@ class App extends Component {
     };
 
     render() {
-        const { dashboard, encryption } = this.props;
+        const { dashboard, encryption, getVersion } = this.props;
         const updateAvailable = dashboard.isCoreRunning && dashboard.isUpdateAvailable;
 
         return (
@@ -109,7 +109,12 @@ class App extends Component {
                             </Fragment>
                         )}
                     </div>
-                    <Footer />
+                    <Footer
+                        dnsVersion={dashboard.dnsVersion}
+                        dnsPort={dashboard.dnsPort}
+                        processingVersion={dashboard.processingVersion}
+                        getVersion={getVersion}
+                    />
                     <Toasts />
                     <Icons />
                 </Fragment>
@@ -127,6 +132,7 @@ App.propTypes = {
     error: PropTypes.string,
     changeLanguage: PropTypes.func,
     encryption: PropTypes.object,
+    getVersion: PropTypes.func,
 };
 
 export default withNamespaces()(App);
diff --git a/client/src/components/Header/Header.css b/client/src/components/Header/Header.css
index 23eb099f..ddf858c2 100644
--- a/client/src/components/Header/Header.css
+++ b/client/src/components/Header/Header.css
@@ -29,6 +29,7 @@
 .nav-tabs .nav-link {
     width: 100%;
     border: 0;
+    padding: 20px 0;
 }
 
 .header {
@@ -68,42 +69,8 @@
     overflow: hidden;
 }
 
-.nav-version {
-    padding: 7px 0;
-    font-size: 0.80rem;
-    text-align: right;
-}
-
-.nav-version__value {
-    max-width: 110px;
-    font-weight: 600;
-    text-overflow: ellipsis;
-    white-space: nowrap;
-    overflow: hidden;
-}
-
-@media screen and (min-width: 992px) {
-    .nav-version__value {
-        max-width: 100%;
-        overflow: visible;
-    }
-}
-
-.nav-version__link {
-    position: relative;
-    display: inline-block;
-    border-bottom: 1px dashed #495057;
-    cursor: pointer;
-}
-
-.nav-version__text {
-    display: flex;
-    align-items: center;
-    justify-content: flex-end;
-}
-
 .header-brand-img {
-    height: 32px;
+    height: 24px;
 }
 
 .nav-tabs .nav-item.show .nav-link {
@@ -112,6 +79,56 @@
     border-bottom-color: #66b574;
 }
 
+.header__right {
+    display: flex;
+    align-items: center;
+    justify-content: flex-end;
+    min-width: 100px;
+}
+
+.header__logout {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    width: 25px;
+    height: 25px;
+    min-width: 25px;
+    padding: 2px;
+    margin-left: 10px;
+    color: #9aa0ac;
+}
+
+.header__logout:hover,
+.header__logout:focus {
+    color: #6e7687;
+}
+
+.header__logout-icon {
+    height: 100%;
+}
+
+.header__row {
+    display: flex;
+    align-items: center;
+}
+
+.header__container {
+    width: 100%;
+    max-width: 1200px;
+    padding-right: 0.75rem;
+    padding-left: 0.75rem;
+    margin-right: auto;
+    margin-left: auto;
+}
+
+.header__column:last-child {
+    margin-left: auto;
+}
+
+.nav-tabs {
+    margin: 0;
+}
+
 @media screen and (min-width: 992px) {
     .header {
         padding: 0;
@@ -139,13 +156,31 @@
         box-shadow: none;
     }
 
-    .nav-version {
-        padding: 0;
-    }
-
     .nav-icon {
         display: none;
     }
+
+    .header-brand-img {
+        height: 32px;
+    }
+
+    .header__logout {
+        width: 35px;
+        height: 35px;
+        padding: 5px;
+    }
+
+    .header__row {
+        justify-content: space-between;
+    }
+
+    .header__column:last-child {
+        margin-left: 0;
+    }
+
+    .nav-tabs {
+        margin: 0 -0.75rem;
+    }
 }
 
 @media screen and (min-width: 1280px) {
@@ -153,10 +188,6 @@
         font-size: 14px;
     }
 
-    .nav-version {
-        font-size: 0.85rem;
-    }
-
     .nav-icon {
         display: block;
     }
diff --git a/client/src/components/Header/Menu.js b/client/src/components/Header/Menu.js
index 654efae8..2b473e4b 100644
--- a/client/src/components/Header/Menu.js
+++ b/client/src/components/Header/Menu.js
@@ -26,7 +26,7 @@ class Menu extends Component {
 
     render() {
         const menuClass = classnames({
-            'col-lg-6 mobile-menu': true,
+            'header__column mobile-menu': true,
             'mobile-menu--active': this.props.isMenuOpen,
         });
 
diff --git a/client/src/components/Header/index.js b/client/src/components/Header/index.js
index 07e64241..ecd3ea25 100644
--- a/client/src/components/Header/index.js
+++ b/client/src/components/Header/index.js
@@ -5,7 +5,6 @@ import classnames from 'classnames';
 import { Trans, withNamespaces } from 'react-i18next';
 
 import Menu from './Menu';
-import Version from './Version';
 import logo from '../ui/svg/logo.svg';
 import './Header.css';
 
@@ -23,7 +22,7 @@ class Header extends Component {
     };
 
     render() {
-        const { dashboard, getVersion, location } = this.props;
+        const { dashboard, location } = this.props;
         const { isMenuOpen } = this.state;
         const badgeClass = classnames({
             'badge dns-status': true,
@@ -33,21 +32,24 @@ class Header extends Component {
 
         return (
             <div className="header">
-                <div className="container">
-                    <div className="row align-items-center">
-                        <div className="header-toggler d-lg-none ml-2 ml-lg-0 collapsed" onClick={this.toggleMenuOpen}>
+                <div className="header__container">
+                    <div className="header__row">
+                        <div
+                            className="header-toggler d-lg-none ml-lg-0 collapsed"
+                            onClick={this.toggleMenuOpen}
+                        >
                             <span className="header-toggler-icon"></span>
                         </div>
-                        <div className="col col-lg-3">
+                        <div className="header__column">
                             <div className="d-flex align-items-center">
                                 <Link to="/" className="nav-link pl-0 pr-1">
                                     <img src={logo} alt="" className="header-brand-img" />
                                 </Link>
-                                {!dashboard.proccessing && dashboard.isCoreRunning &&
+                                {!dashboard.proccessing && dashboard.isCoreRunning && (
                                     <span className={badgeClass}>
                                         <Trans>{dashboard.protectionEnabled ? 'on' : 'off'}</Trans>
                                     </span>
-                                }
+                                )}
                             </div>
                         </div>
                         <Menu
@@ -56,14 +58,13 @@ class Header extends Component {
                             toggleMenuOpen={this.toggleMenuOpen}
                             closeMenu={this.closeMenu}
                         />
-                        {!dashboard.processing &&
-                            <div className="col col-sm-6 col-lg-3">
-                                <Version
-                                    { ...dashboard }
-                                    getVersion={getVersion}
-                                />
+                        <div className="header__column">
+                            <div className="header__right">
+                                <a href="/control/logout" className="btn btn-sm btn-outline-secondary">
+                                    <Trans>logout</Trans>
+                                </a>
                             </div>
-                        }
+                        </div>
                     </div>
                 </div>
             </div>
@@ -75,6 +76,7 @@ Header.propTypes = {
     dashboard: PropTypes.object.isRequired,
     location: PropTypes.object.isRequired,
     getVersion: PropTypes.func.isRequired,
+    t: PropTypes.func.isRequired,
 };
 
 export default withNamespaces()(Header);
diff --git a/client/src/components/ui/Footer.css b/client/src/components/ui/Footer.css
index c8343f21..66fbe5e2 100644
--- a/client/src/components/ui/Footer.css
+++ b/client/src/components/ui/Footer.css
@@ -1,3 +1,7 @@
+.footer {
+    padding: 1rem 0;
+}
+
 .footer__row {
     display: flex;
     align-items: center;
@@ -8,6 +12,12 @@
     margin-bottom: 15px;
 }
 
+.footer__column--links {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+}
+
 .footer__column--language {
     min-width: 220px;
     margin-bottom: 0;
@@ -16,7 +26,7 @@
 .footer__link {
     display: inline-block;
     vertical-align: middle;
-    margin-right: 15px;
+    margin-bottom: 8px;
 }
 
 .footer__link--report {
@@ -42,4 +52,12 @@
         min-width: initial;
         margin-left: auto;
     }
+
+    .footer__column--links {
+        display: block;
+    }
+
+    .footer__link {
+        margin: 0 20px 0 0;
+    }
 }
diff --git a/client/src/components/ui/Footer.js b/client/src/components/ui/Footer.js
index fb622878..323f6a5b 100644
--- a/client/src/components/ui/Footer.js
+++ b/client/src/components/ui/Footer.js
@@ -1,8 +1,10 @@
-import React, { Component } from 'react';
+import React, { Component, Fragment } from 'react';
+import PropTypes from 'prop-types';
 import { Trans, withNamespaces } from 'react-i18next';
 import { REPOSITORY, LANGUAGES, PRIVACY_POLICY_LINK } from '../../helpers/constants';
 import i18n from '../../i18n';
 
+import Version from './Version';
 import './Footer.css';
 import './Select.css';
 
@@ -14,42 +16,98 @@ class Footer extends Component {
 
     changeLanguage = (event) => {
         i18n.changeLanguage(event.target.value);
-    }
+    };
 
     render() {
+        const {
+            dnsVersion, processingVersion, getVersion,
+        } = this.props;
+
         return (
-            <footer className="footer">
-                <div className="container">
-                    <div className="footer__row">
-                        <div className="footer__column">
-                            <div className="footer__copyright">
-                                <Trans>copyright</Trans> &copy; {this.getYear()} <a href="https://adguard.com/">AdGuard</a>
+            <Fragment>
+                <footer className="footer">
+                    <div className="container">
+                        <div className="footer__row">
+                            {!dnsVersion && (
+                                <div className="footer__column">
+                                    <div className="footer__copyright">
+                                        <Trans>copyright</Trans> &copy; {this.getYear()}{' '}
+                                        <a href="https://adguard.com/">AdGuard</a>
+                                    </div>
+                                </div>
+                            )}
+                            <div className="footer__column footer__column--links">
+                                <a
+                                    href={REPOSITORY.URL}
+                                    className="footer__link"
+                                    target="_blank"
+                                    rel="noopener noreferrer"
+                                >
+                                    <Trans>homepage</Trans>
+                                </a>
+                                <a
+                                    href={PRIVACY_POLICY_LINK}
+                                    className="footer__link"
+                                    target="_blank"
+                                    rel="noopener noreferrer"
+                                >
+                                    <Trans>privacy_policy</Trans>
+                                </a>
+                                <a
+                                    href={REPOSITORY.ISSUES}
+                                    className="btn btn-outline-primary btn-sm footer__link footer__link--report"
+                                    target="_blank"
+                                    rel="noopener noreferrer"
+                                >
+                                    <Trans>report_an_issue</Trans>
+                                </a>
+                            </div>
+                            <div className="footer__column footer__column--language">
+                                <select
+                                    className="form-control select select--language"
+                                    value={i18n.language}
+                                    onChange={this.changeLanguage}
+                                >
+                                    {LANGUAGES.map(language => (
+                                        <option key={language.key} value={language.key}>
+                                            {language.name}
+                                        </option>
+                                    ))}
+                                </select>
                             </div>
                         </div>
-                        <div className="footer__column">
-                            <a href={REPOSITORY.URL} className="footer__link" target="_blank" rel="noopener noreferrer">
-                                <Trans>homepage</Trans>
-                            </a>
-                            <a href={PRIVACY_POLICY_LINK} className="footer__link" target="_blank" rel="noopener noreferrer">
-                                <Trans>privacy_policy</Trans>
-                            </a>
-                            <a href={REPOSITORY.ISSUES} className="btn btn-outline-primary btn-sm footer__link footer__link--report" target="_blank" rel="noopener noreferrer">
-                                <Trans>report_an_issue</Trans>
-                            </a>
-                        </div>
-                        <div className="footer__column footer__column--language">
-                            <select className="form-control select select--language" value={i18n.language} onChange={this.changeLanguage}>
-                                {LANGUAGES.map(language =>
-                                    <option key={language.key} value={language.key}>
-                                        {language.name}
-                                    </option>)}
-                            </select>
+                    </div>
+                </footer>
+                {dnsVersion && (
+                    <div className="footer">
+                        <div className="container">
+                            <div className="footer__row">
+                                <div className="footer__column">
+                                    <div className="footer__copyright">
+                                        <Trans>copyright</Trans> &copy; {this.getYear()}{' '}
+                                        <a href="https://adguard.com/">AdGuard</a>
+                                    </div>
+                                </div>
+                                <div className="footer__column footer__column--language">
+                                    <Version
+                                        dnsVersion={dnsVersion}
+                                        processingVersion={processingVersion}
+                                        getVersion={getVersion}
+                                    />
+                                </div>
+                            </div>
                         </div>
                     </div>
-                </div>
-            </footer>
+                )}
+            </Fragment>
         );
     }
 }
 
+Footer.propTypes = {
+    dnsVersion: PropTypes.string,
+    processingVersion: PropTypes.bool,
+    getVersion: PropTypes.func,
+};
+
 export default withNamespaces()(Footer);
diff --git a/client/src/components/ui/Icons.js b/client/src/components/ui/Icons.js
index ef2fbf85..1655bc8e 100644
--- a/client/src/components/ui/Icons.js
+++ b/client/src/components/ui/Icons.js
@@ -131,6 +131,10 @@ const Icons = () => (
         <symbol id="question" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2">
             <circle cx="12" cy="12" r="10" /><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" /><line x1="12" y1="17" x2="12" y2="17" />
         </symbol>
+
+        <symbol id="logout" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2">
+            <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line>
+        </symbol>
     </svg>
 );
 
diff --git a/client/src/components/ui/PageTitle.css b/client/src/components/ui/PageTitle.css
index edcaa00b..bf698729 100644
--- a/client/src/components/ui/PageTitle.css
+++ b/client/src/components/ui/PageTitle.css
@@ -4,7 +4,13 @@
 }
 
 .page-title__actions {
-    display: inline-block;
-    vertical-align: baseline;
-    margin-left: 20px;
+    display: block;
+}
+
+@media screen and (min-width: 768px) {
+    .page-title__actions {
+        display: inline-block;
+        vertical-align: baseline;
+        margin-left: 20px;
+    }
 }
diff --git a/client/src/components/ui/Tabler.css b/client/src/components/ui/Tabler.css
index afe6dbc1..5a4482f7 100644
--- a/client/src/components/ui/Tabler.css
+++ b/client/src/components/ui/Tabler.css
@@ -78,7 +78,7 @@ section {
 
 body {
     margin: 0;
-    font-family: "Source Sans Pro", -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif;
+    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif;
     font-size: 0.9375rem;
     font-weight: 400;
     line-height: 1.5;
diff --git a/client/src/components/ui/Version.css b/client/src/components/ui/Version.css
new file mode 100644
index 00000000..5477d5f0
--- /dev/null
+++ b/client/src/components/ui/Version.css
@@ -0,0 +1,40 @@
+
+.version {
+    font-size: 0.80rem;
+}
+
+@media screen and (min-width: 1280px) {
+    .version {
+        font-size: 0.85rem;
+    }
+}
+
+.version__value {
+    font-weight: 600;
+}
+
+@media screen and (min-width: 992px) {
+    .version__value {
+        max-width: 100%;
+        overflow: visible;
+    }
+}
+
+.version__link {
+    position: relative;
+    display: inline-block;
+    border-bottom: 1px dashed #495057;
+    cursor: pointer;
+}
+
+.version__text {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+}
+
+@media screen and (min-width: 992px) {
+    .version__text {
+        justify-content: flex-end;
+    }
+}
diff --git a/client/src/components/Header/Version.js b/client/src/components/ui/Version.js
similarity index 53%
rename from client/src/components/Header/Version.js
rename to client/src/components/ui/Version.js
index 042b47a9..da651dc7 100644
--- a/client/src/components/Header/Version.js
+++ b/client/src/components/ui/Version.js
@@ -2,15 +2,17 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { Trans, withNamespaces } from 'react-i18next';
 
+import './Version.css';
+
 const Version = (props) => {
     const {
-        dnsVersion, dnsAddresses, processingVersion, t,
+        dnsVersion, processingVersion, t,
     } = props;
 
     return (
-        <div className="nav-version">
-            <div className="nav-version__text">
-                <Trans>version</Trans>:&nbsp;<span className="nav-version__value" title={dnsVersion}>{dnsVersion}</span>
+        <div className="version">
+            <div className="version__text">
+                <Trans>version</Trans>:&nbsp;<span className="version__value" title={dnsVersion}>{dnsVersion}</span>
                 <button
                     type="button"
                     className="btn btn-icon btn-icon-sm btn-outline-primary btn-sm ml-2"
@@ -23,24 +25,12 @@ const Version = (props) => {
                     </svg>
                 </button>
             </div>
-            <div className="nav-version__link">
-                <div className="popover__trigger popover__trigger--address">
-                    <Trans>dns_addresses</Trans>
-                </div>
-                <div className="popover__body popover__body--address">
-                    <div className="popover__list">
-                        {dnsAddresses.map(ip => <li key={ip}>{ip}</li>)}
-                    </div>
-                </div>
-            </div>
         </div>
     );
 };
 
 Version.propTypes = {
     dnsVersion: PropTypes.string.isRequired,
-    dnsAddresses: PropTypes.array.isRequired,
-    dnsPort: PropTypes.number.isRequired,
     getVersion: PropTypes.func.isRequired,
     processingVersion: PropTypes.bool.isRequired,
     t: PropTypes.func.isRequired,
diff --git a/client/src/containers/Header.js b/client/src/containers/Header.js
index c253ac66..f230bc33 100644
--- a/client/src/containers/Header.js
+++ b/client/src/containers/Header.js
@@ -1,5 +1,5 @@
 import { connect } from 'react-redux';
-import * as actionCreators from '../actions';
+import { getVersion } from '../actions';
 import Header from '../components/Header';
 
 const mapStateToProps = (state) => {
@@ -8,7 +8,11 @@ const mapStateToProps = (state) => {
     return props;
 };
 
+const mapDispatchToProps = {
+    getVersion,
+};
+
 export default connect(
     mapStateToProps,
-    actionCreators,
+    mapDispatchToProps,
 )(Header);
diff --git a/client/src/helpers/form.js b/client/src/helpers/form.js
index cac9882e..3bd94291 100644
--- a/client/src/helpers/form.js
+++ b/client/src/helpers/form.js
@@ -10,6 +10,7 @@ export const renderField = ({
     placeholder,
     type,
     disabled,
+    autoComplete,
     meta: { touched, error },
 }) => (
     <Fragment>
@@ -20,6 +21,7 @@ export const renderField = ({
             type={type}
             className={className}
             disabled={disabled}
+            autoComplete={autoComplete}
         />
         {!disabled &&
             touched &&
diff --git a/client/src/install/Setup/Setup.css b/client/src/install/Setup/Setup.css
index b71c5f55..11ee1430 100644
--- a/client/src/install/Setup/Setup.css
+++ b/client/src/install/Setup/Setup.css
@@ -1,5 +1,5 @@
 .setup {
-    min-height: calc(100vh - 80px);
+    min-height: calc(100vh - 71px);
     line-height: 1.48;
 }
 
diff --git a/client/src/login/Login/Form.js b/client/src/login/Login/Form.js
new file mode 100644
index 00000000..0524129c
--- /dev/null
+++ b/client/src/login/Login/Form.js
@@ -0,0 +1,75 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Field, reduxForm } from 'redux-form';
+import { Trans, withNamespaces } from 'react-i18next';
+import flow from 'lodash/flow';
+
+import { renderField, required } from '../../helpers/form';
+
+const Form = (props) => {
+    const {
+        handleSubmit, processing, invalid, t,
+    } = props;
+
+    return (
+        <form onSubmit={handleSubmit} className="card">
+            <div className="card-body p-6">
+                <div className="form__group form__group--settings">
+                    <label className="form__label" htmlFor="username">
+                        <Trans>username_label</Trans>
+                    </label>
+                    <Field
+                        name="username"
+                        type="text"
+                        className="form-control"
+                        component={renderField}
+                        placeholder={t('username_placeholder')}
+                        autoComplete="username"
+                        disabled={processing}
+                        validate={[required]}
+                    />
+                </div>
+                <div className="form__group form__group--settings">
+                    <label className="form__label" htmlFor="password">
+                        <Trans>password_label</Trans>
+                    </label>
+                    <Field
+                        id="password"
+                        name="password"
+                        type="password"
+                        className="form-control"
+                        component={renderField}
+                        placeholder={t('password_placeholder')}
+                        autoComplete="current-password"
+                        disabled={processing}
+                        validate={[required]}
+                    />
+                </div>
+                <div className="form-footer">
+                    <button
+                        type="submit"
+                        className="btn btn-success btn-block"
+                        disabled={processing || invalid}
+                    >
+                        <Trans>sign_in</Trans>
+                    </button>
+                </div>
+            </div>
+        </form>
+    );
+};
+
+Form.propTypes = {
+    handleSubmit: PropTypes.func.isRequired,
+    submitting: PropTypes.bool.isRequired,
+    invalid: PropTypes.bool.isRequired,
+    processing: PropTypes.bool.isRequired,
+    t: PropTypes.func.isRequired,
+};
+
+export default flow([
+    withNamespaces(),
+    reduxForm({
+        form: 'loginForm',
+    }),
+])(Form);
diff --git a/client/src/login/Login/Login.css b/client/src/login/Login/Login.css
new file mode 100644
index 00000000..628ff62b
--- /dev/null
+++ b/client/src/login/Login/Login.css
@@ -0,0 +1,47 @@
+.login {
+    display: flex;
+    flex-direction: column;
+    justify-content: space-between;
+    align-items: stretch;
+    min-height: 100vh;
+}
+
+.login__form {
+    margin: auto;
+    padding: 40px 15px 100px;
+    width: 100%;
+    max-width: 24rem;
+}
+
+.login__info {
+    position: relative;
+    text-align: center;
+}
+
+.login__message,
+.login__link {
+    font-size: 14px;
+    font-weight: 400;
+    letter-spacing: 0;
+}
+
+@media screen and (min-width: 992px) {
+    .login__message {
+        position: absolute;
+        top: 40px;
+        padding: 0 15px;
+    }
+}
+
+.form__group {
+    position: relative;
+    margin-bottom: 15px;
+}
+
+.form__message {
+    font-size: 11px;
+}
+
+.form__message--error {
+    color: #cd201f;
+}
diff --git a/client/src/login/Login/index.js b/client/src/login/Login/index.js
new file mode 100644
index 00000000..424a7a9d
--- /dev/null
+++ b/client/src/login/Login/index.js
@@ -0,0 +1,90 @@
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import flow from 'lodash/flow';
+import { withNamespaces, Trans } from 'react-i18next';
+
+import * as actionCreators from '../../actions/login';
+import logo from '../../components/ui/svg/logo.svg';
+import Toasts from '../../components/Toasts';
+import Footer from '../../components/ui/Footer';
+import Form from './Form';
+
+import './Login.css';
+import '../../components/ui/Tabler.css';
+
+class Login extends Component {
+    state = {
+        isForgotPasswordVisible: false,
+    };
+
+    handleSubmit = ({ username: name, password }) => {
+        this.props.processLogin({ name, password });
+    };
+
+    toggleText = () => {
+        this.setState(prevState => ({
+            isForgotPasswordVisible: !prevState.isForgotPasswordVisible,
+        }));
+    };
+
+    render() {
+        const { processingLogin } = this.props.login;
+        const { isForgotPasswordVisible } = this.state;
+
+        return (
+            <div className="login">
+                <div className="login__form">
+                    <div className="text-center mb-6">
+                        <img src={logo} className="h-6" alt="logo" />
+                    </div>
+                    <Form onSubmit={this.handleSubmit} processing={processingLogin} />
+                    <div className="login__info">
+                        <button
+                            type="button"
+                            className="btn btn-link login__link"
+                            onClick={this.toggleText}
+                        >
+                            <Trans>forgot_password</Trans>
+                        </button>
+                        {isForgotPasswordVisible && (
+                            <div className="login__message">
+                                <Trans
+                                    components={[
+                                        <a
+                                            href="https://github.com/AdguardTeam/AdGuardHome/wiki/Configuration#password-reset"
+                                            key="0"
+                                            target="_blank"
+                                            rel="noopener noreferrer"
+                                        >
+                                            link
+                                        </a>,
+                                    ]}
+                                >
+                                    forgot_password_desc
+                                </Trans>
+                            </div>
+                        )}
+                    </div>
+                </div>
+                <Footer />
+                <Toasts />
+            </div>
+        );
+    }
+}
+
+Login.propTypes = {
+    login: PropTypes.object.isRequired,
+    processLogin: PropTypes.func.isRequired,
+};
+
+const mapStateToProps = ({ login, toasts }) => ({ login, toasts });
+
+export default flow([
+    withNamespaces(),
+    connect(
+        mapStateToProps,
+        actionCreators,
+    ),
+])(Login);
diff --git a/client/src/login/index.js b/client/src/login/index.js
new file mode 100644
index 00000000..df35137a
--- /dev/null
+++ b/client/src/login/index.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { Provider } from 'react-redux';
+
+import '../components/App/index.css';
+import '../components/ui/ReactTable.css';
+import configureStore from '../configureStore';
+import reducers from '../reducers/login';
+import '../i18n';
+import Login from './Login';
+
+const store = configureStore(reducers, {}); // set initial state
+ReactDOM.render(
+    <Provider store={store}>
+        <Login />
+    </Provider>,
+    document.getElementById('root'),
+);
diff --git a/client/src/reducers/index.js b/client/src/reducers/index.js
index 00a19816..589da42e 100644
--- a/client/src/reducers/index.js
+++ b/client/src/reducers/index.js
@@ -79,8 +79,8 @@ const dashboard = handleActions(
                 dnsVersion: version,
                 dnsPort,
                 dnsAddresses,
-                upstreamDns: upstreamDns.join('\n'),
-                bootstrapDns: bootstrapDns.join('\n'),
+                upstreamDns: (upstreamDns && upstreamDns.join('\n')) || '',
+                bootstrapDns: (bootstrapDns && bootstrapDns.join('\n')) || '',
                 allServers,
                 protectionEnabled,
                 language,
diff --git a/client/src/reducers/login.js b/client/src/reducers/login.js
new file mode 100644
index 00000000..e4c860a9
--- /dev/null
+++ b/client/src/reducers/login.js
@@ -0,0 +1,24 @@
+import { combineReducers } from 'redux';
+import { handleActions } from 'redux-actions';
+import { reducer as formReducer } from 'redux-form';
+
+import * as actions from '../actions/login';
+import toasts from './toasts';
+
+const login = handleActions({
+    [actions.processLoginRequest]: state => ({ ...state, processingLogin: true }),
+    [actions.processLoginFailure]: state => ({ ...state, processingLogin: false }),
+    [actions.processLoginSuccess]: (state, { payload }) => ({
+        ...state, ...payload, processingLogin: false,
+    }),
+}, {
+    processingLogin: false,
+    email: '',
+    password: '',
+});
+
+export default combineReducers({
+    login,
+    toasts,
+    form: formReducer,
+});
diff --git a/client/webpack.common.js b/client/webpack.common.js
index 350fca12..32248b14 100644
--- a/client/webpack.common.js
+++ b/client/webpack.common.js
@@ -10,8 +10,10 @@ const CopyPlugin = require('copy-webpack-plugin');
 const RESOURCES_PATH = path.resolve(__dirname);
 const ENTRY_REACT = path.resolve(RESOURCES_PATH, 'src/index.js');
 const ENTRY_INSTALL = path.resolve(RESOURCES_PATH, 'src/install/index.js');
+const ENTRY_LOGIN = path.resolve(RESOURCES_PATH, 'src/login/index.js');
 const HTML_PATH = path.resolve(RESOURCES_PATH, 'public/index.html');
 const HTML_INSTALL_PATH = path.resolve(RESOURCES_PATH, 'public/install.html');
+const HTML_LOGIN_PATH = path.resolve(RESOURCES_PATH, 'public/login.html');
 const FAVICON_PATH = path.resolve(RESOURCES_PATH, 'public/favicon.png');
 
 const PUBLIC_PATH = path.resolve(__dirname, '../build/static');
@@ -22,6 +24,7 @@ const config = {
     entry: {
         main: ENTRY_REACT,
         install: ENTRY_INSTALL,
+        login: ENTRY_LOGIN,
     },
     output: {
         path: PUBLIC_PATH,
@@ -116,6 +119,13 @@ const config = {
             filename: 'install.html',
             template: HTML_INSTALL_PATH,
         }),
+        new HtmlWebpackPlugin({
+            inject: true,
+            cache: false,
+            chunks: ['login'],
+            filename: 'login.html',
+            template: HTML_LOGIN_PATH,
+        }),
         new ExtractTextPlugin({
             filename: '[name].[contenthash].css',
         }),

From 95d9a537eab8c2651078841eda64beb5cbe62cd3 Mon Sep 17 00:00:00 2001
From: Ildar Kamalov <i.kamalov@adguard.com>
Date: Thu, 19 Sep 2019 18:08:28 +0300
Subject: [PATCH 9/9] - client: fix translation

---
 client/src/__locales/en.json          | 2 +-
 client/src/components/Header/index.js | 2 +-
 client/src/components/ui/Icons.js     | 4 ----
 3 files changed, 2 insertions(+), 6 deletions(-)

diff --git a/client/src/__locales/en.json b/client/src/__locales/en.json
index a978b315..d2d7bfe7 100644
--- a/client/src/__locales/en.json
+++ b/client/src/__locales/en.json
@@ -390,7 +390,7 @@
     "password_label": "Password",
     "password_placeholder": "Enter password",
     "sign_in": "Sign in",
-    "logout": "Logout",
+    "sign_out": "Sign out",
     "forgot_password": "Forgot password?",
     "forgot_password_desc": "Please follow <0>these steps</0> to create a new password for your user account."
 }
diff --git a/client/src/components/Header/index.js b/client/src/components/Header/index.js
index ecd3ea25..28fa0767 100644
--- a/client/src/components/Header/index.js
+++ b/client/src/components/Header/index.js
@@ -61,7 +61,7 @@ class Header extends Component {
                         <div className="header__column">
                             <div className="header__right">
                                 <a href="/control/logout" className="btn btn-sm btn-outline-secondary">
-                                    <Trans>logout</Trans>
+                                    <Trans>sign_out</Trans>
                                 </a>
                             </div>
                         </div>
diff --git a/client/src/components/ui/Icons.js b/client/src/components/ui/Icons.js
index 1655bc8e..ef2fbf85 100644
--- a/client/src/components/ui/Icons.js
+++ b/client/src/components/ui/Icons.js
@@ -131,10 +131,6 @@ const Icons = () => (
         <symbol id="question" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2">
             <circle cx="12" cy="12" r="10" /><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" /><line x1="12" y1="17" x2="12" y2="17" />
         </symbol>
-
-        <symbol id="logout" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2">
-            <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line>
-        </symbol>
     </svg>
 );