Commit 4192fb32 authored by ale's avatar ale

Merge branch 'switch-2fa' into 'master'

Let the user pick 2FA method

See merge request !2
parents 8ed88638 230ae972
Pipeline #2835 passed with stages
in 4 minutes and 39 seconds
......@@ -1124,6 +1124,14 @@ var _templatesLogin_otpHtml = []byte(`{{template "header" .}}
</form>
{{if .AuthResponse.Has2FAMethod "u2f"}}
<p>
<a href="{{.URLPrefix}}/login?2fa=u2f">
Use a hardware token instead.
</a>
</p>
{{end}}
{{template "footer" .}}
`)
......@@ -1137,7 +1145,7 @@ func templatesLogin_otpHtml() (*asset, error) {
return nil, err
}
info := bindataFileInfo{name: "templates/login_otp.html", size: 562, mode: os.FileMode(420), modTime: time.Unix(1550307595, 0)}
info := bindataFileInfo{name: "templates/login_otp.html", size: 711, mode: os.FileMode(420), modTime: time.Unix(1556965814, 0)}
a := &asset{bytes: bytes, info: info}
return a, nil
}
......@@ -1226,6 +1234,14 @@ var _templatesLogin_u2fHtml = []byte(`{{template "header" .}}
</form>
{{if .AuthResponse.Has2FAMethod "otp"}}
<p>
<a href="{{.URLPrefix}}/login?2fa=otp">
Use a numeric one-time token instead.
</a>
</p>
{{end}}
{{template "footer" .}}
`)
......@@ -1239,7 +1255,7 @@ func templatesLogin_u2fHtml() (*asset, error) {
return nil, err
}
info := bindataFileInfo{name: "templates/login_u2f.html", size: 512, mode: os.FileMode(420), modTime: time.Unix(1541234815, 0)}
info := bindataFileInfo{name: "templates/login_u2f.html", size: 669, mode: os.FileMode(420), modTime: time.Unix(1556965831, 0)}
a := &asset{bytes: bytes, info: info}
return a, nil
}
......
......@@ -32,8 +32,8 @@ func (c *fakeAuthClient) Authenticate(_ context.Context, req *auth.Request) (*au
return &auth.Response{Status: auth.StatusOK, UserInfo: info}, nil
case req.Username == "test2fa" && p == "password":
return &auth.Response{
Status: auth.StatusInsufficientCredentials,
TFAMethod: auth.TFAMethodOTP,
Status: auth.StatusInsufficientCredentials,
TFAMethods: []auth.TFAMethod{auth.TFAMethodOTP},
}, nil
}
......
......@@ -35,15 +35,13 @@ type loginSession struct {
Username string
Password string
// Cached from the first auth.Response.
U2FSignRequest *u2f.WebSignRequest
// Never actually serialized, just passed on
// loginStateSuccess.
UserInfo *auth.UserInfo
// The auth.Response is cached for 2FA.
AuthResponse *auth.Response
}
var defaultLoginSessionLifetime = 300 * time.Second
// The login session is short-lived, it only needs to last for the duration of
// the login process itself.
var defaultLoginSessionLifetime = 10 * time.Minute
func newLoginSession() *loginSession {
return &loginSession{
......@@ -165,7 +163,7 @@ func (l *loginHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := l.loginCallback(w, req, session.Username, session.Password, session.UserInfo); err != nil {
if err := l.loginCallback(w, req, session.Username, session.Password, session.AuthResponse.UserInfo); err != nil {
log.Printf("login callback error: %v: user=%s", err, session.Username)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
......@@ -193,10 +191,8 @@ func (l *loginHandler) dispatch(w http.ResponseWriter, req *http.Request, sessio
switch session.State {
case loginStatePassword:
return l.handlePassword(w, req, session)
case loginStateOTP:
return l.handleOTP(w, req, session)
case loginStateU2F:
return l.handleU2F(w, req, session)
case loginStateOTP, loginStateU2F:
return l.handle2FA(w, req, session)
}
return loginStateNone, nil, errors.New("unreachable")
}
......@@ -222,18 +218,19 @@ func (l *loginHandler) handlePassword(w http.ResponseWriter, req *http.Request,
case auth.StatusOK:
session.Username = username
session.Password = password
session.UserInfo = resp.UserInfo
session.AuthResponse = resp
return loginStateSuccess, nil, nil
case auth.StatusInsufficientCredentials:
session.Username = username
session.Password = password
// If there is a U2F challenge in the auth
// response, store it so we can render it
// later.
session.AuthResponse = resp
// Always prefer U2F if supported, default to OTP. We
// are assuming that the auth.Response is well formed,
// and TFAMethods is not nil.
var nextState loginState = loginStateOTP
if resp.TFAMethod == auth.TFAMethodU2F {
if resp.Has2FAMethod(auth.TFAMethodU2F) {
nextState = loginStateU2F
session.U2FSignRequest = resp.U2FSignRequest
}
return nextState, nil, nil
}
......@@ -244,18 +241,46 @@ func (l *loginHandler) handlePassword(w http.ResponseWriter, req *http.Request,
return loginStateNone, body, err
}
func (l *loginHandler) handle2FA(w http.ResponseWriter, req *http.Request, session *loginSession) (loginState, []byte, error) {
// The '2fa' request parameter can be used to manually switch between
// 2fa mechanisms. There is no need to pass the parameter through POSTs
// though, as the login session state is sticky.
if switch2fa := auth.TFAMethod(req.FormValue("2fa")); switch2fa != "" {
if !session.AuthResponse.Has2FAMethod(switch2fa) {
return loginStateNone, nil, errors.New("unsupported 2FA method")
}
switch switch2fa {
case auth.TFAMethodOTP:
session.State = loginStateOTP
case auth.TFAMethodU2F:
session.State = loginStateU2F
}
}
switch session.State {
case loginStateOTP:
return l.handleOTP(w, req, session)
case loginStateU2F:
return l.handleU2F(w, req, session)
}
return loginStateNone, nil, errors.New("unreachable")
}
// Handle login with password and TOTP.
func (l *loginHandler) handleOTP(w http.ResponseWriter, req *http.Request, session *loginSession) (loginState, []byte, error) {
otp := req.FormValue("otp")
env := map[string]interface{}{"Error": false}
env := map[string]interface{}{
"AuthResponse": session.AuthResponse,
"Error": false,
}
if req.Method == "POST" && otp != "" {
resp, err := l.makeAuthRequest(w, req, session.Username, session.Password, otp, nil)
if err != nil {
return loginStateNone, nil, err
}
if resp.Status == auth.StatusOK {
session.UserInfo = resp.UserInfo
session.AuthResponse = resp
return loginStateSuccess, nil, nil
}
env["Error"] = true
......@@ -270,7 +295,8 @@ func (l *loginHandler) handleU2F(w http.ResponseWriter, req *http.Request, sessi
u2fresponse := req.FormValue("u2f_response")
env := map[string]interface{}{
"U2FSignRequest": session.U2FSignRequest,
"AuthResponse": session.AuthResponse,
"U2FSignRequest": session.AuthResponse.U2FSignRequest,
"Error": false,
}
if req.Method == "POST" && u2fresponse != "" {
......@@ -284,7 +310,7 @@ func (l *loginHandler) handleU2F(w http.ResponseWriter, req *http.Request, sessi
return loginStateNone, nil, err
}
if resp.Status == auth.StatusOK {
session.UserInfo = resp.UserInfo
session.AuthResponse = resp
return loginStateSuccess, nil, nil
}
env["Error"] = true
......
......@@ -18,4 +18,12 @@
</form>
{{if .AuthResponse.Has2FAMethod "u2f"}}
<p>
<a href="{{.URLPrefix}}/login?2fa=u2f">
Use a hardware token instead.
</a>
</p>
{{end}}
{{template "footer" .}}
......@@ -20,4 +20,12 @@
</form>
{{if .AuthResponse.Has2FAMethod "otp"}}
<p>
<a href="{{.URLPrefix}}/login?2fa=otp">
Use a numeric one-time token instead.
</a>
</p>
{{end}}
{{template "footer" .}}
......@@ -64,12 +64,14 @@ The YAML file should contain a dictionary with the following attributes:
file (using the `services` attribute), it is possible to define
additional services in YAML-encoded files (having a *.yml*
extension), which is more automation-friendly.
* `backends` is a dictionary describing all known backends and their
configuration parameters. The *file* backend is predefined and always
exists (it requires no configuration).
* `backends_dir` (optional) points at a directory containing backend
configuration as YAML-encoded files: all files with a *.yml*
extension will be loaded.
* `rate_limits` defines the global rate limiters and blacklists. See
the *Rate limiting* section below.
* `enabled_backends` is the list of user backends that should be
enabled (the available backends are *file* and *ldap*)
* `ldap_config` specifies the configuration for the LDAP backend --
see the *LDAP Backend* section below
* `user_meta_server` holds the configuration for the user-meta-server
backend:
* `url` is the URL of the service
......@@ -80,6 +82,28 @@ The YAML file should contain a dictionary with the following attributes:
* `memcache_servers` contains a list of memcached server addresses (in
host:port format)
## Example configuration
An example configuration using both the *sql* backend (with default
schema and queries) for normal users, and the *file* backend for admin
users could look like this:
```yaml
---
backends:
sql:
driver: sqlite3
db_uri: users.db
services:
example_service:
backends:
- backend: sql
- backend: file
params:
src: admins.yml
static_groups: [admins]
```
## Rate limiting
Rate limits and blacklists are global (available to all services), to
......@@ -103,12 +127,12 @@ should specify the following attributes:
Each service definition is a dictionary with the following attributes:
* `backends` is a list of user backend specifications, each one should
include a backend-specific configuration under an attribute named
after the backend itself:
* `file` is simply a path to a user list file, see the *File
backend* section below
* `ldap` configues the LDAP backend for this service
* `backends` is a list of user backend specifications, each one a
dictionary/map with the following attributes:
* `backend` must be the name of a backend that appears in the
top-level configuration map *backends*.
* `params` is a map of backend-specific attributes that configure
the backend for this service.
* `static_groups` is a list of group names that users sourced from
this backend will automatically be added to
* `challenge_response` is a boolean parameter that, when true, enables
......@@ -116,6 +140,8 @@ Each service definition is a dictionary with the following attributes:
only for interactive services)
* `enforce_2fa` is a boolean flag that, when true, will disable
non-2FA logins for this service
* `enable_last_login_reporting` is a boolean flag that enables last login
reporting to usermetadb
* `enable_device_tracking` is a boolean flag that enables device
tracking for this service (assuming the client provides device
information)
......@@ -125,30 +151,34 @@ Each service definition is a dictionary with the following attributes:
## File backend
The *file* backend reads users and their credentials from a
YAML-encoded file. This file should contain a list of dictionaries,
each representing a user, with the following attributes:
YAML-encoded file. The service-specific configuration parameters are:
* `src` should point at the users file.
This file should contain a list of dictionaries, each representing a
user, with the following attributes:
* `name` is the username
* `email` is the email associated with the user (optional)
* `password` stores the encrypted password
* `totp_secret` stores the *unencrypted* TOTP secret seed
* `u2f_registrations` is a list of U2F registrations with `key_handle`
and `public_key` attributes, in the format used by *pamu2fcfg* (for
convenience)
* `groups` is a list of group names that the user belongs to
The file backend only supports TOTP as a two-factor authentication
method, U2F support is currently missing.
## LDAP Backend
## LDAP backend
The *ldap* backend will look up user information in a LDAP database.
It needs some parameters to be passed in the top-level *ldap_config*
dictionary:
The backend connects to a single LDAP server and requires the
following top-level configuration:
* `uri` of the LDAP server (like *ldapi:///var/run/ldap/ldapi*)
* `bind_dn` is the DN to bind with
* `bind_pw_file` points at a file containing the bind password
The *ldap* backend will currently always attempt to bind on every
connection.
Each service can then use different queries, as shown in the next
section.
### Query definition
......@@ -201,6 +231,102 @@ App-specific passwords should be encoded as colon-separated strings:
The password should be encrypted. The comment is a free-form string
set by the user to tell the various credentials apart.
## SQL backend
The SQL backend allows you to use a SQL database to store user
information. It can adapt to any schema, provided that you can write
the queries it expects.
The parameters for the SQL backend configuration are:
* `driver` is the name of the database/sql driver (currently it must
be one of `sqlite3`, `mysql` or `postgres`, the built-in drivers)
* `db_uri` is the database URI (a.k.a. DSN), whose exact syntax will
depend on the chosen driver. Check out the documentation for the
database/sql [sqlite](https://github.com/mattn/go-sqlite3),
[mysql](https://github.com/go-sql-driver/mysql) and
[postgres](https://godoc.org/github.com/lib/pq) drivers.
### Query definition
Each service can specify a set of different SQL queries. It can be
configured with the following attributes:
* `queries` holds the map of SQL queries that tell the auth-server
how to query your database.
The known queries are identified by name. It does not matter what
operations you do as long as the queries take the expected input
substitution parameters, and return rows with the expected number of
fields (column names do not matter). You should use the parameter
substitution symbol `?` as placeholder for query parameters.
* `get_user` takes a single parameter (the user name) and must return
a single row with *email*, *password*, *TOTP secret* and *shard*
fields for the matching user.
* `get_user_groups` takes a single parameter (the user name) and must
return rows with a single *group_name* field corresponding to the
user's group memberships.
* `get_user_u2f` takes a single parameter (user name) and must return
the user's U2F registrations as rows with *public_key* and
*key_handle* fields, in their native binary format.
* `get_user_asp` takes a single parameter (user name) and must return
the user's application-specific passwords as rows with *service* and
*password* fields.
The only mandatory query is *get_user*, if the other ones are not
specified the associated fields will be empty.
### Example database schema
The following could be a (very simple) example database schema for a
case where usernames are also email addresses, with support for all
authentication features:
```sql
CREATE TABLE users (
email text NOT NULL,
password text NOT NULL,
totp_secret text,
shard text
);
CREATE UNIQUE INDEX users_email_idx ON users(email);
CREATE TABLE group_memberships (
email text NOT NULL,
group_name text NOT NULL
);
CREATE INDEX group_memberships_idx ON group_memberships(email);
CREATE TABLE u2f_registrations (
email text NOT NULL,
key_handle blob NOT NULL,
public_key blob NOT NULL
);
CREATE INDEX u2f_registrations_idx ON u2f_registrations(email);
CREATE TABLE service_passwords (
email text NOT NULL,
service text NOT NULL,
password text NOT NULL
);
CREATE INDEX service_passwords_idx ON service_passwords(email);
```
With this schema, one could use the following configuration for a
service:
```yaml
services:
example:
challenge_response: true
backends:
- backend: sql
params:
queries:
get_user: "SELECT email, password, totp_secret, shard FROM users WHERE email = ?"
get_user_groups: "SELECT group_name FROM group_memberships WHERE email = ?"
get_user_u2f: "SELECT public_key, key_handle FROM u2f_registrations WHERE email = ?"
get_user_asp: "SELECT service, password FROM service_passwords WHERE email = ?"
```
# Usage
......
......@@ -10,13 +10,13 @@ import (
// simple persistent cookie to track the same client device across
// multiple session.
type DeviceInfo struct {
ID string
RemoteAddr string
RemoteZone string
UserAgent string
Browser string
OS string
Mobile bool
ID string `json:"id"`
RemoteAddr string `json:"remote_addr"`
RemoteZone string `json:"remote_zone"`
UserAgent string `json:"user_agent"`
Browser string `json:"browser"`
OS string `json:"os"`
Mobile bool `json:"mobile"`
}
func (d *DeviceInfo) encodeToMap(m map[string]string, prefix string) {
......@@ -58,7 +58,6 @@ type Request struct {
U2FAppID string
U2FResponse *u2f.SignResponse
DeviceInfo *DeviceInfo
//Extra map[string]string
}
func (r *Request) EncodeToMap(m map[string]string, prefix string) {
......@@ -98,6 +97,25 @@ type UserInfo struct {
Groups []string
}
func encodeStringList(m map[string]string, prefix string, l []string) {
for i, elem := range l {
m[fmt.Sprintf("%s.%d.", prefix, i)] = elem
}
}
func decodeStringList(m map[string]string, prefix string) (out []string) {
i := 0
for {
s, ok := m[fmt.Sprintf("%s.%d.", prefix, i)]
if !ok {
break
}
out = append(out, s)
i++
}
return
}
func (u *UserInfo) EncodeToMap(m map[string]string, prefix string) {
if u.Email != "" {
m[prefix+"email"] = u.Email
......@@ -105,24 +123,14 @@ func (u *UserInfo) EncodeToMap(m map[string]string, prefix string) {
if u.Shard != "" {
m[prefix+"shard"] = u.Shard
}
for i, g := range u.Groups {
m[fmt.Sprintf("%sgroup.%d.", prefix, i)] = g
}
encodeStringList(m, prefix+"group", u.Groups)
}
func decodeUserInfoFromMap(m map[string]string, prefix string) *UserInfo {
u := UserInfo{
Email: m[prefix+"email"],
Shard: m[prefix+"shard"],
}
i := 0
for {
s, ok := m[fmt.Sprintf("%sgroup.%d.", prefix, i)]
if !ok {
break
}
u.Groups = append(u.Groups, s)
i++
Email: m[prefix+"email"],
Shard: m[prefix+"shard"],
Groups: decodeStringList(m, prefix+"group"),
}
if u.Email == "" && u.Shard == "" && len(u.Groups) == 0 {
return nil
......@@ -133,16 +141,49 @@ func decodeUserInfoFromMap(m map[string]string, prefix string) *UserInfo {
// Response to an authentication request.
type Response struct {
Status Status
TFAMethod TFAMethod
TFAMethods []TFAMethod
U2FSignRequest *u2f.WebSignRequest
UserInfo *UserInfo
}
// Has2FAMethod checks for the presence of a two-factor authentication
// method in the Response.
func (r *Response) Has2FAMethod(needle TFAMethod) bool {
for _, m := range r.TFAMethods {
if m == needle {
return true
}
}
return false
}
func encodeTFAMethodList(m map[string]string, prefix string, l []TFAMethod) {
if len(l) == 0 {
return
}
tmp := make([]string, 0, len(l))
for _, el := range l {
tmp = append(tmp, string(el))
}
encodeStringList(m, prefix, tmp)
}
func decodeTFAMethodList(m map[string]string, prefix string) []TFAMethod {
l := decodeStringList(m, prefix)
if len(l) == 0 {
return nil
}
out := make([]TFAMethod, 0, len(l))
for _, el := range l {
out = append(out, TFAMethod(el))
}
return out
}
func (r *Response) EncodeToMap(m map[string]string, prefix string) {
m[prefix+"status"] = r.Status.String()
m[prefix+"2fa_method"] = string(r.TFAMethod)
encodeTFAMethodList(m, prefix+"2fa_methods", r.TFAMethods)
if r.U2FSignRequest != nil {
// External type.
encodeU2FSignRequestToMap(r.U2FSignRequest, m, prefix+"u2f_req.")
}
if r.UserInfo != nil {
......@@ -152,7 +193,7 @@ func (r *Response) EncodeToMap(m map[string]string, prefix string) {
func (r *Response) DecodeFromMap(m map[string]string, prefix string) {
r.Status = parseAuthStatus(m[prefix+"status"])
r.TFAMethod = TFAMethod(m[prefix+"2fa_method"])
r.TFAMethods = decodeTFAMethodList(m, prefix+"2fa_methods")
r.U2FSignRequest = decodeU2FSignRequestFromMap(m, prefix+"u2f_req.")
r.UserInfo = decodeUserInfoFromMap(m, prefix+"user.")
}
......
......@@ -27,16 +27,16 @@
"revisionTime": "2019-01-29T12:17:45Z"
},
{
"checksumSHA1": "6D5Xt9WoGSeTJE3XFw6P2/nKYrQ=",
"checksumSHA1": "T9WPwUls+LPk89st6TGCbQf5HNQ=",
"path": "git.autistici.org/id/auth",
"revision": "1276ec2bd95945ea45a8680a15b76cad8a339096",
"revisionTime": "2018-11-22T22:30:45Z"
"revision": "b9fd25734d1e2a7f92f32ab982f9c55fd9f9ef24",
"revisionTime": "2019-05-04T07:53:15Z"
},
{
"checksumSHA1": "t3JTZ0bAMQit79HYbcEykC8uxro=",
"path": "git.autistici.org/id/auth/client",
"revision": "1276ec2bd95945ea45a8680a15b76cad8a339096",
"revisionTime": "2018-11-22T22:30:45Z"
"revision": "b9fd25734d1e2a7f92f32ab982f9c55fd9f9ef24",
"revisionTime": "2019-05-04T07:53:15Z"
},
{
"checksumSHA1": "MlpsZgRytv/c9IX9YawRJDN/ibQ=",
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment