Merge: + Log-In page

Close #865

* commit '95d9a537eab8c2651078841eda64beb5cbe62cd3':
  - client: fix translation
  + client: login page
  * client: fix translation string
  * minor
  *(home): fix golint issues
  - fix crash after stats module is closed
  + config: upgrade from v4 to v5
  + openapi: /login, /logout
  + Login page and web sessions
This commit is contained in:
Simon Zolin 2019-09-19 18:16:37 +03:00
commit f6404ef181
41 changed files with 1316 additions and 164 deletions

View File

@ -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

17
client/public/login.html Normal file
View File

@ -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>

View File

@ -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 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",
"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."
}

View File

@ -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}} дней"
}

View File

@ -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());
}
};

View File

@ -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();

View File

@ -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);

View File

@ -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;
}

View File

@ -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,
});

View File

@ -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>sign_out</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);

View File

@ -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;
}
}

View File

@ -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 (
<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>
<Trans>copyright</Trans> &copy; {this.getYear()}{' '}
<a href="https://adguard.com/">AdGuard</a>
</div>
</div>
<div className="footer__column">
<a href={REPOSITORY.URL} className="footer__link" target="_blank" rel="noopener noreferrer">
)}
<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">
<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">
<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 =>
<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>)}
</option>
))}
</select>
</div>
</div>
</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>
)}
</Fragment>
);
}
}
Footer.propTypes = {
dnsVersion: PropTypes.string,
processingVersion: PropTypes.bool,
getVersion: PropTypes.func,
};
export default withNamespaces()(Footer);

View File

@ -3,8 +3,14 @@
font-size: 0.9rem;
}
.page-title__actions {
display: block;
}
@media screen and (min-width: 768px) {
.page-title__actions {
display: inline-block;
vertical-align: baseline;
margin-left: 20px;
}
}

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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,

View File

@ -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);

View File

@ -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 &&

View File

@ -1,5 +1,5 @@
.setup {
min-height: calc(100vh - 80px);
min-height: calc(100vh - 71px);
line-height: 1.48;
}

View File

@ -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);

View File

@ -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;
}

View File

@ -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);

18
client/src/login/index.js Normal file
View File

@ -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'),
);

View File

@ -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,

View File

@ -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,
});

View File

@ -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',
}),

View File

@ -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
}

3
go.mod
View File

@ -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

412
home/auth.go Normal file
View File

@ -0,0 +1,412 @@
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 {
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)
}
// 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

@ -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

View File

@ -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)
}
}

View File

@ -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)

View File

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

View File

@ -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

View File

@ -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()
}
@ -200,8 +204,16 @@ func stopDNSServer() error {
return errorx.Decorate(err, "Couldn't stop forwarding DNS server")
}
config.stats.Close()
config.queryLog.Close()
// 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
}

View File

@ -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
// -------------------

View File

@ -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
}

View File

@ -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.

View File

@ -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"
@ -1674,3 +1702,13 @@ definitions:
type: "string"
description: "Basic auth password"
example: "password"
Login:
type: "object"
description: "Login request data"
properties:
username:
type: "string"
description: "User name"
password:
type: "string"
description: "Password"

View File

@ -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