From d0fa32ce2a25ee6bf60d289bba40d78d9e21f202 Mon Sep 17 00:00:00 2001 From: ale <ale@incal.net> Date: Sat, 4 May 2019 09:02:33 +0100 Subject: [PATCH] Update to new id/auth API that returns all supported 2FA methods First step towards letting users pick the method they prefer. --- server/http_test.go | 4 +- server/login.go | 29 ++-- vendor/git.autistici.org/id/auth/README.md | 166 ++++++++++++++++--- vendor/git.autistici.org/id/auth/protocol.go | 93 ++++++++--- vendor/vendor.json | 10 +- 5 files changed, 233 insertions(+), 69 deletions(-) diff --git a/server/http_test.go b/server/http_test.go index c4020ac..fa711be 100644 --- a/server/http_test.go +++ b/server/http_test.go @@ -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 } diff --git a/server/login.go b/server/login.go index cc7f5f0..e511e8e 100644 --- a/server/login.go +++ b/server/login.go @@ -35,12 +35,8 @@ 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 @@ -165,7 +161,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 @@ -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 } @@ -255,7 +252,7 @@ func (l *loginHandler) handleOTP(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 @@ -270,7 +267,7 @@ func (l *loginHandler) handleU2F(w http.ResponseWriter, req *http.Request, sessi u2fresponse := req.FormValue("u2f_response") env := map[string]interface{}{ - "U2FSignRequest": session.U2FSignRequest, + "U2FSignRequest": session.AuthResponse.U2FSignRequest, "Error": false, } if req.Method == "POST" && u2fresponse != "" { @@ -284,7 +281,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 diff --git a/vendor/git.autistici.org/id/auth/README.md b/vendor/git.autistici.org/id/auth/README.md index cf5eead..b25a67f 100644 --- a/vendor/git.autistici.org/id/auth/README.md +++ b/vendor/git.autistici.org/id/auth/README.md @@ -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 diff --git a/vendor/git.autistici.org/id/auth/protocol.go b/vendor/git.autistici.org/id/auth/protocol.go index 3db526f..2c0896a 100644 --- a/vendor/git.autistici.org/id/auth/protocol.go +++ b/vendor/git.autistici.org/id/auth/protocol.go @@ -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.") } diff --git a/vendor/vendor.json b/vendor/vendor.json index cfdc0f1..0d52d1e 100644 --- a/vendor/vendor.json +++ b/vendor/vendor.json @@ -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=", -- GitLab