Requirements
High-level tasks:
- mithril model and API changes - ave phone number, get services. Changes in concept user, party employee, party_users.
- Change Create employee process - add 2nd factor phone number, add phone number verification step
- Change approve employee process - two-step approval, resend sms
- Change login process - step, resend sms, otp counter, block user after n logins failed
- Existing users migration - move the mobile phone number to the 2nd factor
- Mithril admin changes - show 2nd factor, block/unblock user
- Introduce user endpoints to MIS
Assumtions:
- We keep using oauth 2.0 flow.
- 2factor auth will be introduced for all users at the same time
- mobile phone number for all the existing users should be automatically used as the 2nd factor
- 2nd factor can be changed only via create employee request
- 2nd factor - OTP delivered via sms
- 2-factor auth can be applied to all the admin consoles: mithrill, uaddresses, nhs.admin - configurable
Key aspects of implementation
- We support a few kind of factors, but user have only 1 active factor at the same time
- Now we implement only `SMS` factor type
- We add blocking process for normal login process (login + password)
Configs
2FA & OTP configurable parameters:
Params | Type | Purpose |
---|---|---|
OTP_ERROR_MAX | integer | Maximum for entered in-error trying for OTP verification process for OTP item. After excess of quantity entered in-error trying, status for OTP automatically sets to BLOCKED. |
OTP_LIFETIME | integer | Life-time for OTP item |
OTP_LENGTH | integer | Length of OTP value |
USER_LOGIN_ERROR_MAX | integer | Maximum for entered in-error trying for login verification process for all user life-time. After excess of quantity entered in-error trying, status for user automatically sets to BLOCKED. |
USER_OTP_ERROR_MAX | integer | Maximum for entered in-error trying for OTP verification process for all user life-time. After excess of quantity entered in-error trying, status for user automatically sets to BLOCKED. |
USER_2FA_ENABLED | boolean | Flag which determinate is a default activating 2FA for new user. |
Data structure
Store 2FA structure for User
According ability uses many factors of authentication - propose use separated new entity `authentication_factors` with a structure:
Column Type Purpose id uuid user_id uuid User FK type varchar factor varchar Factor value is_active boolean inserted_at timestamp updateed_at timestamp additional constraint: create unique index (user_id, type)
where factor is string which depends on factor type, examples:
type factor SMS
"+380677778899" PHONE "+380677778899" EMAIL "pupkin.vasya@gmail.com"
Change User entity
- We need store for user 3 attribute:
- count logins fail
- count 2FA fail
- block status of user
Propose store counters parameters for 2FA in `users.priv_settings` as structure :
{ "login_error_counter": 0, "otp_error_counter": 0 }
Propose store information about blocked directly in entity `users`.
New columns:Column Type Purpose is_blocked boolean block_reason varchar (255), NULL
Сonstraints
- user have one active 2FA factor ( one of any type)
- unique index for `authentication_factors` on user_id+type
2FA state chart
We need minimal 4 state for user & 2FA:
- DISABLED - Disable 2FA for this user (via NHS Admin)
- BLOCKED - User blocked after N-unsuccessful attempts via authentication process.
- RESET - NHS Admin reset value of factor or factor type. At first login user must fill value of factor.
- ACTIVE - normal status: 2FA enable
Propose don't use statuses model, and use logic calculating for state 2FA user:
Logic status for 2FA & user | Purpose |
---|---|
RESET | User don't blocked (`is_blocked`=false) & exist 1 active item in `authentication_factors` with empty value of factor . ( factor = NULL or factor = "" ) |
ACTIVE | User don't blocked (`is_blocked`=false) & exist 1 active item in `authentication_factors` with fill value of factor. Examples for type = SMS: ( factor= "+380677778899" ) |
DISABLED | User don't blocked (`is_blocked`=false) & not exist any active items in `authentication_factors` -- return 0 rows) |
BLOCKED | User have `is_blocked`=true (SELECT * FROM users AS U --- return 1 rows) |
OTP Module
Data structure
Entity `OTP` with a structure:
Column | Type | Purpose |
---|---|---|
id | uuid | |
key | varchar | Value of factor (1 active 2FA for user) |
code | varchar | Value of OTP |
status | varchar | Status (Dictionary: OTP_STATUS) |
code_expired_at | timestamp | Timestamp which OTP expired (now() + param from config) |
attempts_count | integer | Count of trying OTP authorization process |
updateed_at | timestamp |
OTP states
Dictionary: `OTP_STATUS`
Value Purpose NEW Initial status for newest OTP item. Ready to use in OTP verification process. VERIFIED Status for successful OTP verification process UNVERIFIED Status for unsuccessful OTP verification process EXPIRED Expired after OTP lifetime. CANCELED Status for manual admin action.
- Status Chart for OTP
- TransitionsFromTransitionResult
Created new OTP status = NEW status = NEW Succseful OTP verify status = VERIFIED status = NEW Unsuccseful OTP verify afterexcess of count [param: OTP_ERROR_MAX] status = UNVERIFIED status = NEW [AUTO] Termination process after end of life-time OTP [param: OTP_LIFETIME] status = EXPIRED status = NEW All OTP by `key` in status=`NEW` before creating new OTP status = CANCELED
Services specifications
1- WS.Create User (Modification) DONE
Purpose
- Add `is_blocked`, `block_reason` column in user entity
- Add logic for create 2FA item for new user
Logic WS
Create user with this structure in `users.priv_settings`:
{ "login_error_counter": 0, "otp_error_counter": 0 }
and:
- is_blocked = FALSE
- block_reason = NULL
- .....
- After insert new user in `users`
- Get config-param `USER_2FA_ENABLED`
- If `USER_2FA_ENABLED` = TRUE
- Insert new record in `authentication_factors` with logical status = RESET
- user_id = $.user_id
- type = `SMS`
- factor = NULL
- is_active = TRUE
- update_at = now()
- Insert new record in `authentication_factors` with logical status = RESET
2- WS: DisableUser2FA (new EP) DONE
APIary
PATCH mithril/api/users/{id}/actions/disable2fa
Scope: `user:disable2fa`
Purpose
Disable 2FA for user (via Admin-console).
Request parameters
- user_id
Logic WS
- Validate token & scope
- Validate user id & user status
Get active 2FA item for non-blocked user by $.user_id
SELECT * FROM authentication_factors AS 2FA INNER JOIN user AS U ON 2FA.user_id = U.id WHERE U.id = $.user_id AND U.is_active = TRUE AND U.is_blocked = FALSE AND 2FA.is_active = TRUE
- If exist 2FA item for user → update 2FA item (set values):
- 2FA.is_active = FALSE
- 2FA.update_at = now()
- If exist 2FA item for user → update 2FA item (set values):
- Return 200
- ...
Response
- 200 if 2FA successful disable
- 4xx in other case
3- WS: ResetUser2FA (new EP) DONE
APIary
PATCH mithril/api/users/{id}/actions/reset2fa
Scope: `user:reset2fa`
Purpose
Reset current factor value for 2FA for user (via Admin-console).
Request parameters
- user_id
Logic WS
- Validate token & scope
- Validate user id & user status
Get active 2FA item for non-blocked user by $.user_id
SELECT * FROM authentication_factors AS 2FA INNER JOIN user AS U ON 2FA.user_id = U.id WHERE U.id = $.user_id AND U.is_active = TRUE AND U.is_blocked = FALSE AND 2FA.is_active = TRUE
- If exist 2FA item for user → update 2FA item (set values) to logical status = RESET :
- 2FA.factor = NULL
- 2FA.update_at = now()
- If exist 2FA item for user → update 2FA item (set values) to logical status = RESET :
- Return 200
- ...
Response
- 200 if 2FA successful reset
- 4xx in other case
4- WS: UnBlockUser (new EP) DONE
APIary
PATCH mithril/api/users/{id}/actions/unblock
Scope: `user:unblock`
Purpose
Unblock user (via Admin-console).
Request parameters
- user_id
Logic WS
- Validate token & scope
- Validate user id & user status
- Update user (set values) by $.user_id
- is_blocked = FALSE
- update_at = now()
- Return 200
- ...
Response
- 200 if user successful unblock
- 4xx in other case
5- WS: BlockUser (new EP) DONE
APIary
PATCH mithril/api/users/{id}/actions/block
Scope: `user:block`
Purpose
Unblock user (via Admin-console).
Request parameters
- user_id
- block_reason
Logic WS
- Validate token & scope
- Validate user id & user status
- Update user (set values) by $.user_id
- is_blocked = TRUE
- block_reason = $.block_reason
- update_at = now()
- Return 200
- ...
Response
- 200 if user successful block
- 4xx in other case
6- WS: Create Token (Modification) TBD
APIary
Purpose
Logic WS
Response
7- WS: Create Approvals (Modification) TBD
APIary
Purpose
Logic WS
Response
8- WS: Create OTP (New EP) DONE
APIary
PATCH mithril/api/users/{id}/actions/sendOTP
Scope: <NO SCOPE>
Purpose
Create & Send new OTP item for user via current 2FA factor
Request parameters
- user_id
- token
Logic WS
- Validate token (2fa_access_token) - ????
- If invalid - return error 4xx
- Validate user id & user status
Get active 2FA item for non-blocked user by $.user_id
SELECT * FROM authentication_factors AS 2FA WHERE 2FA.user_id = $.user_id AND 2FA.is_active = TRUE
- If not found - return 409 error "Not found 2FA data for user"
- Extract type & factor from 2FA item for user
- Invoke internal function `create OTP (key)`, for 2FA.type = SMS, with params:
- key = 2FA.faсtor
- Get result of call `create OTP()` as `OTP_value`
- Sending (delivery) OTP via channel communication
- for 2FA.type = SMS - via SMS gateway API
- mobile phone = 2FA.factor
- SMS text = OTP_value
- for 2FA.type = SMS - via SMS gateway API
- Return 200
- ...
Internal logic for `create OTP()`
- Processed OTP lists for $.key
- Deactivate all active OTP items (NEW → CANCELED)
- Create new OTP item
- status = NEW
- expired_at = now() + OTP_LIFETIME
- value = generate number according OTP_LENGTH
- updated_at = now()
Response
- 200 if OTP successful create & send
- 4xx in other case
9- WS: Authorize via 2FA (New EP) DONE
APIary
PATCH mithril/api/users/{id}/actions/authorize2fa
Scope: <NO SCOPE>
Purpose
Verify OTP & prolongation authentication process (return access_token for getting approvals) .
Request parameters
- user_id
- otp
- token
Logic WS
- Validate token (2fa_access_token) - ????
- If invalid - return error 4xx
- Validate user id & user status
Get active 2FA item for non-blocked user by $.user_id
SELECT * FROM authentication_factors AS 2FA WHERE 2FA.user_id = $.user_id AND 2FA.is_active = TRUE
- If not found - return 409 error "Not found 2FA data for user"
- Extract type & factor from 2FA item for user
- Invoke internal function `verify OTP (key, code)`, for 2FA.type = SMS, with params:
- key = 2FA.faсtor
- code = $.otp
- Get result of call `verify OTP()`
- If result = VERIFIED
- Update user (set values) by $.user_id
users.priv_settings.otp_error_counter = 0
- Update 2fa_access_token (set `tokens.details.used`=true)
- Create & return new access_token (as a existing standart process without 2FA)
- Return 200
- Update user (set values) by $.user_id
- If result = UNVERIFIED
- Update user (set values) by $.user_id
- Increment `users.priv_settings.otp_error_counter` (+1)
- If `users.priv_settings.otp_error_counter` > USER_OTP_ERROR_MAX
- Blocked user - update user (set values) by $.user_id
- is_blocked = TRUE
- block_reason = "excess USER_OTP_ERROR_MAX"
- updated_at = now()
- Blocked user - update user (set values) by $.user_id
- return 401 error
- Update user (set values) by $.user_id
Internal logic for `verify OTP()`
- Find 1 active OTP (status = NEW) for $.key
- If not found - return 409 error "Not found active OTP" - ??? or 401 ?
- If found - increment `attempts_count` (+1) for this OTP
- If OTP.code = $.code - update OTP item:
- OTP status ( NEW → VERIFIED)
- updated_at = now()
- If (OTP.code <> $.code ) AND (OTP.attempts_count > OTP_ERROR_MAX )
- Update OTP item
- status ( NEW → UNVERIFIED)
- updated_at = now()
- Update OTP item
- If OTP.code = $.code - update OTP item:
Response
- 200 if OTP successful create & send
- 4xx in other case
10- WS: Change 2FA (New EP) TBD
APIary
Purpose
Logic WS
Response
11- PROC: [AUTO] Terminate OTP (new PROC) DONE
Purpose
AUTO-Terminating expired OTP.
Logic
Fetch records from `otp`, where:
- status = 'NEW'
- expired_at < now()
- Update (Set values) OTP items
- status = EXPIRED
- updated_at = now()