mirror of
				https://github.com/mail-in-a-box/mailinabox.git
				synced 2025-11-03 19:30:54 +00:00 
			
		
		
		
	Add TOTP two-factor authentication to admin panel login (#1814)
* add user interface for managing 2fa * update user schema with 2fa columns * implement two factor check during login * Use pyotp for validating TOTP codes * also implements resynchronisation support via `pyotp`'s `valid_window option * Update API route naming, update setup page * Rename /two-factor-auth/ => /2fa/ * Nest totp routes under /2fa/totp/ * Update ids and methods in panel to allow for different setup types * Autofocus otp input when logging in, update layout * Extract TOTPStrategy class to totp.py * this decouples `TOTP` validation and storage logic from `auth` and moves it to `totp` * reduce `pyotp.validate#valid_window` from `2` to `1` * Update OpenApi docs, rename /2fa/ => /mfa/ * Decouple totp from users table by moving to totp_credentials table * this allows implementation of other mfa schemes in the future (webauthn) * also makes key management easier and enforces one totp credentials per user on db-level * Add sqlite migration * Rename internal validate_two_factor_secret => validate_two_factor_secret * conn.close() if mru_token update can't .commit() * Address review feedback, thanks @hija * Use hmac.compare_digest() to compare mru_token * Safeguard against empty mru_token column * hmac.compare_digest() expects arguments of type string, make sure we don't pass None * Currently, this cannot happen but we might not want to store `mru_token` during setup * Do not log failed login attempts for MissingToken errors * Due to the way that the /login UI works, this persists at least one failed login each time a user logs into the admin panel. This in turn triggers fail2ban at some point. * Add TOTP secret to user_key hash thanks @downtownallday * this invalidates all user_keys after TOTP status is changed for user * after changing TOTP state, a login is required * due to the forced login, we can't and don't need to store the code used for setup in `mru_code` * Typo * Reorganize the MFA backend methods * Reorganize MFA front-end and add label column * Fix handling of bad input when enabling mfa * Update openAPI docs * Remove unique key constraint on foreign key user_id in mfa table * Don't expose mru_token and secret for enabled mfas over HTTP * Only update mru_token for matched mfa row * Exclude mru_token in user key hash * Rename tools/mail.py to management/cli.py * Add MFA list/disable to the management CLI so admins can restore access if MFA device is lost Co-authored-by: Joshua Tauberer <jt@occams.info>
This commit is contained in:
		
						commit
						6a979f4f52
					
				@ -8,7 +8,7 @@ info:
 | 
				
			|||||||
    This API is documented in [**OpenAPI format**](http://spec.openapis.org/oas/v3.0.3).
 | 
					    This API is documented in [**OpenAPI format**](http://spec.openapis.org/oas/v3.0.3).
 | 
				
			||||||
    ([View the full HTTP specification](https://raw.githubusercontent.com/mail-in-a-box/mailinabox/api-spec/api/mailinabox.yml).)
 | 
					    ([View the full HTTP specification](https://raw.githubusercontent.com/mail-in-a-box/mailinabox/api-spec/api/mailinabox.yml).)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    All endpoints are relative to `https://{host}/admin` and are secured with [`Basic Access` authentication](https://en.wikipedia.org/wiki/Basic_access_authentication).
 | 
					    All endpoints are relative to `https://{host}/admin` and are secured with [`Basic Access` authentication](https://en.wikipedia.org/wiki/Basic_access_authentication). If you have multi-factor authentication enabled, authentication with a `user:password` combination will fail unless a valid OTP is supplied via the `x-auth-token` header. Authentication via a `user:user_key` pair is possible without the header being present.
 | 
				
			||||||
  contact:
 | 
					  contact:
 | 
				
			||||||
    name: Mail-in-a-Box support
 | 
					    name: Mail-in-a-Box support
 | 
				
			||||||
    url: https://mailinabox.email/
 | 
					    url: https://mailinabox.email/
 | 
				
			||||||
@ -46,6 +46,9 @@ tags:
 | 
				
			|||||||
  - name: Web
 | 
					  - name: Web
 | 
				
			||||||
    description: |
 | 
					    description: |
 | 
				
			||||||
      Static web hosting operations, which include getting domain information and updating domain root directories.
 | 
					      Static web hosting operations, which include getting domain information and updating domain root directories.
 | 
				
			||||||
 | 
					  - name: MFA
 | 
				
			||||||
 | 
					    description: |
 | 
				
			||||||
 | 
					      Manage multi-factor authentication schemes. Currently, only TOTP is supported.
 | 
				
			||||||
  - name: System
 | 
					  - name: System
 | 
				
			||||||
    description: |
 | 
					    description: |
 | 
				
			||||||
      System operations, which include system status checks, new version checks
 | 
					      System operations, which include system status checks, new version checks
 | 
				
			||||||
@ -1662,6 +1665,101 @@ paths:
 | 
				
			|||||||
            text/html:
 | 
					            text/html:
 | 
				
			||||||
              schema:
 | 
					              schema:
 | 
				
			||||||
                type: string
 | 
					                type: string
 | 
				
			||||||
 | 
					  /mfa/status:
 | 
				
			||||||
 | 
					    post:
 | 
				
			||||||
 | 
					      tags:
 | 
				
			||||||
 | 
					        - MFA
 | 
				
			||||||
 | 
					      summary: Retrieve MFA status for you or another user
 | 
				
			||||||
 | 
					      description: Retrieves which type of MFA is used and configuration
 | 
				
			||||||
 | 
					      operationId: mfaStatus
 | 
				
			||||||
 | 
					      x-codeSamples:
 | 
				
			||||||
 | 
					        - lang: curl
 | 
				
			||||||
 | 
					          source: |
 | 
				
			||||||
 | 
					            curl -X POST "https://{host}/admin/mfa/status" \
 | 
				
			||||||
 | 
					              -u "<email>:<password>"
 | 
				
			||||||
 | 
					      responses:
 | 
				
			||||||
 | 
					        200:
 | 
				
			||||||
 | 
					          description: Successful operation
 | 
				
			||||||
 | 
					          content:
 | 
				
			||||||
 | 
					            application/json:
 | 
				
			||||||
 | 
					              schema:
 | 
				
			||||||
 | 
					                $ref: '#/components/schemas/MfaStatusResponse'
 | 
				
			||||||
 | 
					        403:
 | 
				
			||||||
 | 
					          description: Forbidden
 | 
				
			||||||
 | 
					          content:
 | 
				
			||||||
 | 
					            text/html:
 | 
				
			||||||
 | 
					              schema:
 | 
				
			||||||
 | 
					                type: string
 | 
				
			||||||
 | 
					  /mfa/totp/enable:
 | 
				
			||||||
 | 
					    post:
 | 
				
			||||||
 | 
					      tags:
 | 
				
			||||||
 | 
					        - MFA
 | 
				
			||||||
 | 
					      summary: Enable TOTP authentication
 | 
				
			||||||
 | 
					      description: Enables TOTP authentication for the currently logged-in admin user
 | 
				
			||||||
 | 
					      operationId: mfaTotpEnable
 | 
				
			||||||
 | 
					      x-codeSamples:
 | 
				
			||||||
 | 
					        - lang: curl
 | 
				
			||||||
 | 
					          source: |
 | 
				
			||||||
 | 
					            curl -X POST "https://{host}/admin/mfa/totp/enable" \
 | 
				
			||||||
 | 
					              -d "code=123456" \
 | 
				
			||||||
 | 
					              -d "secret=<string>" \
 | 
				
			||||||
 | 
					              -u "<email>:<password>"
 | 
				
			||||||
 | 
					      requestBody:
 | 
				
			||||||
 | 
					        required: true
 | 
				
			||||||
 | 
					        content:
 | 
				
			||||||
 | 
					          application/x-www-form-urlencoded:
 | 
				
			||||||
 | 
					            schema:
 | 
				
			||||||
 | 
					              $ref: '#/components/schemas/MfaEnableRequest'
 | 
				
			||||||
 | 
					      responses:
 | 
				
			||||||
 | 
					        200:
 | 
				
			||||||
 | 
					          description: Successful operation
 | 
				
			||||||
 | 
					          content:
 | 
				
			||||||
 | 
					            text/html:
 | 
				
			||||||
 | 
					              schema:
 | 
				
			||||||
 | 
					                $ref: '#/components/schemas/MfaEnableSuccessResponse'
 | 
				
			||||||
 | 
					        400:
 | 
				
			||||||
 | 
					          description: Bad request
 | 
				
			||||||
 | 
					          content:
 | 
				
			||||||
 | 
					            text/html:
 | 
				
			||||||
 | 
					              schema:
 | 
				
			||||||
 | 
					                type: string
 | 
				
			||||||
 | 
					        403:
 | 
				
			||||||
 | 
					          description: Forbidden
 | 
				
			||||||
 | 
					          content:
 | 
				
			||||||
 | 
					            text/html:
 | 
				
			||||||
 | 
					              schema:
 | 
				
			||||||
 | 
					                type: string
 | 
				
			||||||
 | 
					  /mfa/disable:
 | 
				
			||||||
 | 
					    post:
 | 
				
			||||||
 | 
					      tags:
 | 
				
			||||||
 | 
					        - MFA
 | 
				
			||||||
 | 
					      summary: Disable multi-factor authentication for you or another user
 | 
				
			||||||
 | 
					      description: Disables multi-factor authentication for the currently logged-in admin user or another user if a 'user' parameter is submitted. Either disables all multi-factor authentication methods or the method corresponding to the optional property `mfa_id`.
 | 
				
			||||||
 | 
					      operationId: mfaTotpDisable
 | 
				
			||||||
 | 
					      requestBody:
 | 
				
			||||||
 | 
					        required: false
 | 
				
			||||||
 | 
					        content:
 | 
				
			||||||
 | 
					          application/x-www-form-urlencoded:
 | 
				
			||||||
 | 
					            schema:
 | 
				
			||||||
 | 
					              $ref: '#/components/schemas/MfaDisableRequest'
 | 
				
			||||||
 | 
					      x-codeSamples:
 | 
				
			||||||
 | 
					        - lang: curl
 | 
				
			||||||
 | 
					          source: |
 | 
				
			||||||
 | 
					            curl -X POST "https://{host}/admin/mfa/totp/disable" \
 | 
				
			||||||
 | 
					              -u "<email>:<user_key>"
 | 
				
			||||||
 | 
					      responses:
 | 
				
			||||||
 | 
					        200:
 | 
				
			||||||
 | 
					          description: Successful operation
 | 
				
			||||||
 | 
					          content:
 | 
				
			||||||
 | 
					            text/html:
 | 
				
			||||||
 | 
					              schema:
 | 
				
			||||||
 | 
					                $ref: '#/components/schemas/MfaDisableSuccessResponse'
 | 
				
			||||||
 | 
					        403:
 | 
				
			||||||
 | 
					          description: Forbidden
 | 
				
			||||||
 | 
					          content:
 | 
				
			||||||
 | 
					            text/html:
 | 
				
			||||||
 | 
					              schema:
 | 
				
			||||||
 | 
					                type: string
 | 
				
			||||||
components:
 | 
					components:
 | 
				
			||||||
  securitySchemes:
 | 
					  securitySchemes:
 | 
				
			||||||
    basicAuth:
 | 
					    basicAuth:
 | 
				
			||||||
@ -2529,3 +2627,54 @@ components:
 | 
				
			|||||||
      type: string
 | 
					      type: string
 | 
				
			||||||
      example: web updated
 | 
					      example: web updated
 | 
				
			||||||
      description: Web update response.
 | 
					      description: Web update response.
 | 
				
			||||||
 | 
					    MfaStatusResponse:
 | 
				
			||||||
 | 
					      type: object
 | 
				
			||||||
 | 
					      properties:
 | 
				
			||||||
 | 
					        enabled_mfa:
 | 
				
			||||||
 | 
					          type: object
 | 
				
			||||||
 | 
					          properties:
 | 
				
			||||||
 | 
					            id:
 | 
				
			||||||
 | 
					              type: string
 | 
				
			||||||
 | 
					            type:
 | 
				
			||||||
 | 
					              type: string
 | 
				
			||||||
 | 
					            label:
 | 
				
			||||||
 | 
					              type: string
 | 
				
			||||||
 | 
					          nullable: true
 | 
				
			||||||
 | 
					        new_mfa:
 | 
				
			||||||
 | 
					          type: object
 | 
				
			||||||
 | 
					          properties:
 | 
				
			||||||
 | 
					            type:
 | 
				
			||||||
 | 
					              type: string
 | 
				
			||||||
 | 
					            secret:
 | 
				
			||||||
 | 
					              type: string
 | 
				
			||||||
 | 
					            qr_code_base64:
 | 
				
			||||||
 | 
					              type: string
 | 
				
			||||||
 | 
					    MfaEnableRequest:
 | 
				
			||||||
 | 
					      type: object
 | 
				
			||||||
 | 
					      required:
 | 
				
			||||||
 | 
					        - secret
 | 
				
			||||||
 | 
					        - code
 | 
				
			||||||
 | 
					      properties:
 | 
				
			||||||
 | 
					        secret:
 | 
				
			||||||
 | 
					          type: string
 | 
				
			||||||
 | 
					        code:
 | 
				
			||||||
 | 
					          type: string
 | 
				
			||||||
 | 
					        label:
 | 
				
			||||||
 | 
					          type: string
 | 
				
			||||||
 | 
					    MfaEnableSuccessResponse:
 | 
				
			||||||
 | 
					      type: string
 | 
				
			||||||
 | 
					    MfaEnableBadRequestResponse:
 | 
				
			||||||
 | 
					      type: object
 | 
				
			||||||
 | 
					      required:
 | 
				
			||||||
 | 
					        - error
 | 
				
			||||||
 | 
					      properties:
 | 
				
			||||||
 | 
					        error:
 | 
				
			||||||
 | 
					          type: string
 | 
				
			||||||
 | 
					    MfaDisableRequest:
 | 
				
			||||||
 | 
					      type: object
 | 
				
			||||||
 | 
					      properties:
 | 
				
			||||||
 | 
					        mfa_id:
 | 
				
			||||||
 | 
					          type: string
 | 
				
			||||||
 | 
					          nullable: true
 | 
				
			||||||
 | 
					    MfaDisableSuccessResponse:
 | 
				
			||||||
 | 
					      type: string
 | 
				
			||||||
 | 
				
			|||||||
@ -1,9 +1,10 @@
 | 
				
			|||||||
import base64, os, os.path, hmac
 | 
					import base64, os, os.path, hmac, json
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from flask import make_response
 | 
					from flask import make_response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import utils
 | 
					import utils
 | 
				
			||||||
from mailconfig import get_mail_password, get_mail_user_privileges
 | 
					from mailconfig import get_mail_password, get_mail_user_privileges
 | 
				
			||||||
 | 
					from mfa import get_hash_mfa_state, validate_auth_mfa
 | 
				
			||||||
 | 
					
 | 
				
			||||||
DEFAULT_KEY_PATH   = '/var/lib/mailinabox/api.key'
 | 
					DEFAULT_KEY_PATH   = '/var/lib/mailinabox/api.key'
 | 
				
			||||||
DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server'
 | 
					DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server'
 | 
				
			||||||
@ -72,17 +73,19 @@ class KeyAuthService:
 | 
				
			|||||||
		if username in (None, ""):
 | 
							if username in (None, ""):
 | 
				
			||||||
			raise ValueError("Authorization header invalid.")
 | 
								raise ValueError("Authorization header invalid.")
 | 
				
			||||||
		elif username == self.key:
 | 
							elif username == self.key:
 | 
				
			||||||
			# The user passed the API key which grants administrative privs.
 | 
								# The user passed the master API key which grants administrative privs.
 | 
				
			||||||
			return (None, ["admin"])
 | 
								return (None, ["admin"])
 | 
				
			||||||
		else:
 | 
							else:
 | 
				
			||||||
			# The user is trying to log in with a username and user-specific
 | 
								# The user is trying to log in with a username and either a password
 | 
				
			||||||
			# API key or password. Raises or returns privs.
 | 
								# (and possibly a MFA token) or a user-specific API key.
 | 
				
			||||||
			return (username, self.get_user_credentials(username, password, env))
 | 
								return (username, self.check_user_auth(username, password, request, env))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	def get_user_credentials(self, email, pw, env):
 | 
						def check_user_auth(self, email, pw, request, env):
 | 
				
			||||||
		# Validate a user's credentials. On success returns a list of
 | 
							# Validate a user's login email address and password. If MFA is enabled,
 | 
				
			||||||
		# privileges (e.g. [] or ['admin']). On failure raises a ValueError
 | 
							# check the MFA token in the X-Auth-Token header.
 | 
				
			||||||
		# with a login error message. 
 | 
							#
 | 
				
			||||||
 | 
							# On success returns a list of privileges (e.g. [] or ['admin']). On login
 | 
				
			||||||
 | 
							# failure, raises a ValueError with a login error message.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		# Sanity check.
 | 
							# Sanity check.
 | 
				
			||||||
		if email == "" or pw == "":
 | 
							if email == "" or pw == "":
 | 
				
			||||||
@ -112,6 +115,12 @@ class KeyAuthService:
 | 
				
			|||||||
				# Login failed.
 | 
									# Login failed.
 | 
				
			||||||
				raise ValueError("Invalid password.")
 | 
									raise ValueError("Invalid password.")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								# If MFA is enabled, check that MFA passes.
 | 
				
			||||||
 | 
								status, hints = validate_auth_mfa(email, request, env)
 | 
				
			||||||
 | 
								if not status:
 | 
				
			||||||
 | 
									# Login valid. Hints may have more info.
 | 
				
			||||||
 | 
									raise ValueError(",".join(hints))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		# Get privileges for authorization. This call should never fail because by this
 | 
							# Get privileges for authorization. This call should never fail because by this
 | 
				
			||||||
		# point we know the email address is a valid user. But on error the call will
 | 
							# point we know the email address is a valid user. But on error the call will
 | 
				
			||||||
		# return a tuple of an error message and an HTTP status code.
 | 
							# return a tuple of an error message and an HTTP status code.
 | 
				
			||||||
@ -122,16 +131,27 @@ class KeyAuthService:
 | 
				
			|||||||
		return privs
 | 
							return privs
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	def create_user_key(self, email, env):
 | 
						def create_user_key(self, email, env):
 | 
				
			||||||
		# Store an HMAC with the client. The hashed message of the HMAC will be the user's
 | 
							# Create a user API key, which is a shared secret that we can re-generate from
 | 
				
			||||||
		# email address & hashed password and the key will be the master API key. The user of
 | 
							# static information in our database. The shared secret contains the user's
 | 
				
			||||||
		# course has their own email address and password. We assume they do not have the master
 | 
							# email address, current hashed password, and current MFA state, so that the
 | 
				
			||||||
		# API key (unless they are trusted anyway). The HMAC proves that they authenticated
 | 
							# key becomes invalid if any of that information changes.
 | 
				
			||||||
		# with us in some other way to get the HMAC. Including the password means that when
 | 
							#
 | 
				
			||||||
		# a user's password is reset, the HMAC changes and they will correctly need to log
 | 
							# Use an HMAC to generate the API key using our master API key as a key,
 | 
				
			||||||
		# in to the control panel again. This method raises a ValueError if the user does
 | 
							# which also means that the API key becomes invalid when our master API key
 | 
				
			||||||
		# not exist, due to get_mail_password.
 | 
							# changes --- i.e. when this process is restarted.
 | 
				
			||||||
 | 
							#
 | 
				
			||||||
 | 
							# Raises ValueError via get_mail_password if the user doesn't exist.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							# Construct the HMAC message from the user's email address and current password.
 | 
				
			||||||
		msg = b"AUTH:" + email.encode("utf8") + b" " + get_mail_password(email, env).encode("utf8")
 | 
							msg = b"AUTH:" + email.encode("utf8") + b" " + get_mail_password(email, env).encode("utf8")
 | 
				
			||||||
		return hmac.new(self.key.encode('ascii'), msg, digestmod="sha256").hexdigest()
 | 
					
 | 
				
			||||||
 | 
							# Add to the message the current MFA state, which is a list of MFA information.
 | 
				
			||||||
 | 
							# Turn it into a string stably.
 | 
				
			||||||
 | 
							msg += b" " + json.dumps(get_hash_mfa_state(email, env), sort_keys=True).encode("utf8")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							# Make the HMAC.
 | 
				
			||||||
 | 
							hash_key = self.key.encode('ascii')
 | 
				
			||||||
 | 
							return hmac.new(hash_key, msg, digestmod="sha256").hexdigest()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	def _generate_key(self):
 | 
						def _generate_key(self):
 | 
				
			||||||
		raw_key = os.urandom(32)
 | 
							raw_key = os.urandom(32)
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										150
									
								
								management/cli.py
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										150
									
								
								management/cli.py
									
									
									
									
									
										Executable file
									
								
							@ -0,0 +1,150 @@
 | 
				
			|||||||
 | 
					#!/usr/bin/python3
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# This is a command-line script for calling management APIs
 | 
				
			||||||
 | 
					# on the Mail-in-a-Box control panel backend. The script
 | 
				
			||||||
 | 
					# reads /var/lib/mailinabox/api.key for the backend's
 | 
				
			||||||
 | 
					# root API key. This file is readable only by root, so this
 | 
				
			||||||
 | 
					# tool can only be used as root.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import sys, getpass, urllib.request, urllib.error, json, re, csv
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def mgmt(cmd, data=None, is_json=False):
 | 
				
			||||||
 | 
						# The base URL for the management daemon. (Listens on IPv4 only.)
 | 
				
			||||||
 | 
						mgmt_uri = 'http://127.0.0.1:10222'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						setup_key_auth(mgmt_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						req = urllib.request.Request(mgmt_uri + cmd, urllib.parse.urlencode(data).encode("utf8") if data else None)
 | 
				
			||||||
 | 
						try:
 | 
				
			||||||
 | 
							response = urllib.request.urlopen(req)
 | 
				
			||||||
 | 
						except urllib.error.HTTPError as e:
 | 
				
			||||||
 | 
							if e.code == 401:
 | 
				
			||||||
 | 
								try:
 | 
				
			||||||
 | 
									print(e.read().decode("utf8"))
 | 
				
			||||||
 | 
								except:
 | 
				
			||||||
 | 
									pass
 | 
				
			||||||
 | 
								print("The management daemon refused access. The API key file may be out of sync. Try 'service mailinabox restart'.", file=sys.stderr)
 | 
				
			||||||
 | 
							elif hasattr(e, 'read'):
 | 
				
			||||||
 | 
								print(e.read().decode('utf8'), file=sys.stderr)
 | 
				
			||||||
 | 
							else:
 | 
				
			||||||
 | 
								print(e, file=sys.stderr)
 | 
				
			||||||
 | 
							sys.exit(1)
 | 
				
			||||||
 | 
						resp = response.read().decode('utf8')
 | 
				
			||||||
 | 
						if is_json: resp = json.loads(resp)
 | 
				
			||||||
 | 
						return resp
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def read_password():
 | 
				
			||||||
 | 
					    while True:
 | 
				
			||||||
 | 
					        first = getpass.getpass('password: ')
 | 
				
			||||||
 | 
					        if len(first) < 8:
 | 
				
			||||||
 | 
					            print("Passwords must be at least eight characters.")
 | 
				
			||||||
 | 
					            continue
 | 
				
			||||||
 | 
					        second = getpass.getpass(' (again): ')
 | 
				
			||||||
 | 
					        if first != second:
 | 
				
			||||||
 | 
					            print("Passwords not the same. Try again.")
 | 
				
			||||||
 | 
					            continue
 | 
				
			||||||
 | 
					        break
 | 
				
			||||||
 | 
					    return first
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def setup_key_auth(mgmt_uri):
 | 
				
			||||||
 | 
						key = open('/var/lib/mailinabox/api.key').read().strip()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						auth_handler = urllib.request.HTTPBasicAuthHandler()
 | 
				
			||||||
 | 
						auth_handler.add_password(
 | 
				
			||||||
 | 
							realm='Mail-in-a-Box Management Server',
 | 
				
			||||||
 | 
							uri=mgmt_uri,
 | 
				
			||||||
 | 
							user=key,
 | 
				
			||||||
 | 
							passwd='')
 | 
				
			||||||
 | 
						opener = urllib.request.build_opener(auth_handler)
 | 
				
			||||||
 | 
						urllib.request.install_opener(opener)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if len(sys.argv) < 2:
 | 
				
			||||||
 | 
						print("""Usage:
 | 
				
			||||||
 | 
					  {cli} user                                     (lists users)
 | 
				
			||||||
 | 
					  {cli} user add user@domain.com [password]
 | 
				
			||||||
 | 
					  {cli} user password user@domain.com [password]
 | 
				
			||||||
 | 
					  {cli} user remove user@domain.com
 | 
				
			||||||
 | 
					  {cli} user make-admin user@domain.com
 | 
				
			||||||
 | 
					  {cli} user remove-admin user@domain.com
 | 
				
			||||||
 | 
					  {cli} user admins                              (lists admins)
 | 
				
			||||||
 | 
					  {cli} user mfa show user@domain.com            (shows MFA devices for user, if any)
 | 
				
			||||||
 | 
					  {cli} user mfa disable user@domain.com [id]    (disables MFA for user)
 | 
				
			||||||
 | 
					  {cli} alias                                    (lists aliases)
 | 
				
			||||||
 | 
					  {cli} alias add incoming.name@domain.com sent.to@other.domain.com
 | 
				
			||||||
 | 
					  {cli} alias add incoming.name@domain.com 'sent.to@other.domain.com, multiple.people@other.domain.com'
 | 
				
			||||||
 | 
					  {cli} alias remove incoming.name@domain.com
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Removing a mail user does not delete their mail folders on disk. It only prevents IMAP/SMTP login.
 | 
				
			||||||
 | 
					""".format(
 | 
				
			||||||
 | 
						cli="management/cli.py"
 | 
				
			||||||
 | 
							))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					elif sys.argv[1] == "user" and len(sys.argv) == 2:
 | 
				
			||||||
 | 
						# Dump a list of users, one per line. Mark admins with an asterisk.
 | 
				
			||||||
 | 
						users = mgmt("/mail/users?format=json", is_json=True)
 | 
				
			||||||
 | 
						for domain in users:
 | 
				
			||||||
 | 
							for user in domain["users"]:
 | 
				
			||||||
 | 
								if user['status'] == 'inactive': continue
 | 
				
			||||||
 | 
								print(user['email'], end='')
 | 
				
			||||||
 | 
								if "admin" in user['privileges']:
 | 
				
			||||||
 | 
									print("*", end='')
 | 
				
			||||||
 | 
								print()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					elif sys.argv[1] == "user" and sys.argv[2] in ("add", "password"):
 | 
				
			||||||
 | 
						if len(sys.argv) < 5:
 | 
				
			||||||
 | 
							if len(sys.argv) < 4:
 | 
				
			||||||
 | 
								email = input("email: ")
 | 
				
			||||||
 | 
							else:
 | 
				
			||||||
 | 
								email = sys.argv[3]
 | 
				
			||||||
 | 
							pw = read_password()
 | 
				
			||||||
 | 
						else:
 | 
				
			||||||
 | 
							email, pw = sys.argv[3:5]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if sys.argv[2] == "add":
 | 
				
			||||||
 | 
							print(mgmt("/mail/users/add", { "email": email, "password": pw }))
 | 
				
			||||||
 | 
						elif sys.argv[2] == "password":
 | 
				
			||||||
 | 
							print(mgmt("/mail/users/password", { "email": email, "password": pw }))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					elif sys.argv[1] == "user" and sys.argv[2] == "remove" and len(sys.argv) == 4:
 | 
				
			||||||
 | 
						print(mgmt("/mail/users/remove", { "email": sys.argv[3] }))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					elif sys.argv[1] == "user" and sys.argv[2] in ("make-admin", "remove-admin") and len(sys.argv) == 4:
 | 
				
			||||||
 | 
						if sys.argv[2] == "make-admin":
 | 
				
			||||||
 | 
							action = "add"
 | 
				
			||||||
 | 
						else:
 | 
				
			||||||
 | 
							action = "remove"
 | 
				
			||||||
 | 
						print(mgmt("/mail/users/privileges/" + action, { "email": sys.argv[3], "privilege": "admin" }))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					elif sys.argv[1] == "user" and sys.argv[2] == "admins":
 | 
				
			||||||
 | 
						# Dump a list of admin users.
 | 
				
			||||||
 | 
						users = mgmt("/mail/users?format=json", is_json=True)
 | 
				
			||||||
 | 
						for domain in users:
 | 
				
			||||||
 | 
							for user in domain["users"]:
 | 
				
			||||||
 | 
								if "admin" in user['privileges']:
 | 
				
			||||||
 | 
									print(user['email'])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					elif sys.argv[1] == "user" and len(sys.argv) == 5 and sys.argv[2:4] == ["mfa", "show"]:
 | 
				
			||||||
 | 
						# Show MFA status for a user.
 | 
				
			||||||
 | 
						status = mgmt("/mfa/status", { "user": sys.argv[4] }, is_json=True)
 | 
				
			||||||
 | 
						W = csv.writer(sys.stdout)
 | 
				
			||||||
 | 
						W.writerow(["id", "type", "label"])
 | 
				
			||||||
 | 
						for mfa in status["enabled_mfa"]:
 | 
				
			||||||
 | 
							W.writerow([mfa["id"], mfa["type"], mfa["label"]])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					elif sys.argv[1] == "user" and len(sys.argv) in (5, 6) and sys.argv[2:4] == ["mfa", "disable"]:
 | 
				
			||||||
 | 
						# Disable MFA (all or a particular device) for a user.
 | 
				
			||||||
 | 
						print(mgmt("/mfa/disable", { "user": sys.argv[4], "mfa-id": sys.argv[5] if len(sys.argv) == 6 else None }))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					elif sys.argv[1] == "alias" and len(sys.argv) == 2:
 | 
				
			||||||
 | 
						print(mgmt("/mail/aliases"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					elif sys.argv[1] == "alias" and sys.argv[2] == "add" and len(sys.argv) == 5:
 | 
				
			||||||
 | 
						print(mgmt("/mail/aliases/add", { "address": sys.argv[3], "forwards_to": sys.argv[4] }))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4:
 | 
				
			||||||
 | 
						print(mgmt("/mail/aliases/remove", { "address": sys.argv[3] }))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					else:
 | 
				
			||||||
 | 
						print("Invalid command-line arguments.")
 | 
				
			||||||
 | 
						sys.exit(1)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -1,14 +1,15 @@
 | 
				
			|||||||
import os, os.path, re, json, time
 | 
					import os, os.path, re, json, time
 | 
				
			||||||
import subprocess
 | 
					import multiprocessing.pool, subprocess
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from functools import wraps
 | 
					from functools import wraps
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from flask import Flask, request, render_template, abort, Response, send_from_directory, make_response
 | 
					from flask import Flask, request, render_template, abort, Response, send_from_directory, make_response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import auth, utils, multiprocessing.pool
 | 
					import auth, utils
 | 
				
			||||||
from mailconfig import get_mail_users, get_mail_users_ex, get_admins, add_mail_user, set_mail_password, remove_mail_user
 | 
					from mailconfig import get_mail_users, get_mail_users_ex, get_admins, add_mail_user, set_mail_password, remove_mail_user
 | 
				
			||||||
from mailconfig import get_mail_user_privileges, add_remove_mail_user_privilege
 | 
					from mailconfig import get_mail_user_privileges, add_remove_mail_user_privilege
 | 
				
			||||||
from mailconfig import get_mail_aliases, get_mail_aliases_ex, get_mail_domains, add_mail_alias, remove_mail_alias
 | 
					from mailconfig import get_mail_aliases, get_mail_aliases_ex, get_mail_domains, add_mail_alias, remove_mail_alias
 | 
				
			||||||
 | 
					from mfa import get_public_mfa_state, provision_totp, validate_totp_secret, enable_mfa, disable_mfa
 | 
				
			||||||
 | 
					
 | 
				
			||||||
env = utils.load_environment()
 | 
					env = utils.load_environment()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -35,23 +36,31 @@ app = Flask(__name__, template_folder=os.path.abspath(os.path.join(os.path.dirna
 | 
				
			|||||||
def authorized_personnel_only(viewfunc):
 | 
					def authorized_personnel_only(viewfunc):
 | 
				
			||||||
	@wraps(viewfunc)
 | 
						@wraps(viewfunc)
 | 
				
			||||||
	def newview(*args, **kwargs):
 | 
						def newview(*args, **kwargs):
 | 
				
			||||||
		# Authenticate the passed credentials, which is either the API key or a username:password pair.
 | 
							# Authenticate the passed credentials, which is either the API key or a username:password pair
 | 
				
			||||||
 | 
							# and an optional X-Auth-Token token.
 | 
				
			||||||
		error = None
 | 
							error = None
 | 
				
			||||||
 | 
							privs = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		try:
 | 
							try:
 | 
				
			||||||
			email, privs = auth_service.authenticate(request, env)
 | 
								email, privs = auth_service.authenticate(request, env)
 | 
				
			||||||
		except ValueError as e:
 | 
							except ValueError as e:
 | 
				
			||||||
			# Authentication failed.
 | 
					 | 
				
			||||||
			privs = []
 | 
					 | 
				
			||||||
			error = "Incorrect username or password"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			# Write a line in the log recording the failed login
 | 
								# Write a line in the log recording the failed login
 | 
				
			||||||
			log_failed_login(request)
 | 
								log_failed_login(request)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								# Authentication failed.
 | 
				
			||||||
 | 
								error = str(e)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		# Authorized to access an API view?
 | 
							# Authorized to access an API view?
 | 
				
			||||||
		if "admin" in privs:
 | 
							if "admin" in privs:
 | 
				
			||||||
 | 
								# Store the email address of the logged in user so it can be accessed
 | 
				
			||||||
 | 
								# from the API methods that affect the calling user.
 | 
				
			||||||
 | 
								request.user_email = email
 | 
				
			||||||
 | 
								request.user_privs = privs
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			# Call view func.
 | 
								# Call view func.
 | 
				
			||||||
			return viewfunc(*args, **kwargs)
 | 
								return viewfunc(*args, **kwargs)
 | 
				
			||||||
		elif not error:
 | 
					
 | 
				
			||||||
 | 
							if not error:
 | 
				
			||||||
			error = "You are not an administrator."
 | 
								error = "You are not an administrator."
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		# Not authorized. Return a 401 (send auth) and a prompt to authorize by default.
 | 
							# Not authorized. Return a 401 (send auth) and a prompt to authorize by default.
 | 
				
			||||||
@ -83,8 +92,8 @@ def authorized_personnel_only(viewfunc):
 | 
				
			|||||||
def unauthorized(error):
 | 
					def unauthorized(error):
 | 
				
			||||||
	return auth_service.make_unauthorized_response()
 | 
						return auth_service.make_unauthorized_response()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def json_response(data):
 | 
					def json_response(data, status=200):
 | 
				
			||||||
	return Response(json.dumps(data, indent=2, sort_keys=True)+'\n', status=200, mimetype='application/json')
 | 
						return Response(json.dumps(data, indent=2, sort_keys=True)+'\n', status=status, mimetype='application/json')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
###################################
 | 
					###################################
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -119,12 +128,17 @@ def me():
 | 
				
			|||||||
	try:
 | 
						try:
 | 
				
			||||||
		email, privs = auth_service.authenticate(request, env)
 | 
							email, privs = auth_service.authenticate(request, env)
 | 
				
			||||||
	except ValueError as e:
 | 
						except ValueError as e:
 | 
				
			||||||
		# Log the failed login
 | 
							if "missing-totp-token" in str(e):
 | 
				
			||||||
		log_failed_login(request)
 | 
								return json_response({
 | 
				
			||||||
 | 
									"status": "missing-totp-token",
 | 
				
			||||||
		return json_response({
 | 
									"reason": str(e),
 | 
				
			||||||
			"status": "invalid",
 | 
								})
 | 
				
			||||||
			"reason": "Incorrect username or password",
 | 
							else:
 | 
				
			||||||
 | 
								# Log the failed login
 | 
				
			||||||
 | 
								log_failed_login(request)
 | 
				
			||||||
 | 
								return json_response({
 | 
				
			||||||
 | 
									"status": "invalid",
 | 
				
			||||||
 | 
									"reason": str(e),
 | 
				
			||||||
			})
 | 
								})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	resp = {
 | 
						resp = {
 | 
				
			||||||
@ -383,6 +397,60 @@ def ssl_provision_certs():
 | 
				
			|||||||
	requests = provision_certificates(env, limit_domains=None)
 | 
						requests = provision_certificates(env, limit_domains=None)
 | 
				
			||||||
	return json_response({ "requests": requests })
 | 
						return json_response({ "requests": requests })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# multi-factor auth
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@app.route('/mfa/status', methods=['POST'])
 | 
				
			||||||
 | 
					@authorized_personnel_only
 | 
				
			||||||
 | 
					def mfa_get_status():
 | 
				
			||||||
 | 
						# Anyone accessing this route is an admin, and we permit them to
 | 
				
			||||||
 | 
						# see the MFA status for any user if they submit a 'user' form
 | 
				
			||||||
 | 
						# field. But we don't include provisioning info since a user can
 | 
				
			||||||
 | 
						# only provision for themselves.
 | 
				
			||||||
 | 
						email = request.form.get('user', request.user_email) # user field if given, otherwise the user making the request
 | 
				
			||||||
 | 
						try:
 | 
				
			||||||
 | 
							resp = {
 | 
				
			||||||
 | 
								"enabled_mfa": get_public_mfa_state(email, env)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							if email == request.user_email:
 | 
				
			||||||
 | 
								resp.update({
 | 
				
			||||||
 | 
									"new_mfa": {
 | 
				
			||||||
 | 
										"totp": provision_totp(email, env)
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
								})
 | 
				
			||||||
 | 
						except ValueError as e:
 | 
				
			||||||
 | 
							return (str(e), 400)
 | 
				
			||||||
 | 
						return json_response(resp)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@app.route('/mfa/totp/enable', methods=['POST'])
 | 
				
			||||||
 | 
					@authorized_personnel_only
 | 
				
			||||||
 | 
					def totp_post_enable():
 | 
				
			||||||
 | 
						secret = request.form.get('secret')
 | 
				
			||||||
 | 
						token = request.form.get('token')
 | 
				
			||||||
 | 
						label = request.form.get('label')
 | 
				
			||||||
 | 
						if type(token) != str:
 | 
				
			||||||
 | 
							return ("Bad Input", 400)
 | 
				
			||||||
 | 
						try:
 | 
				
			||||||
 | 
							validate_totp_secret(secret)
 | 
				
			||||||
 | 
							enable_mfa(request.user_email, "totp", secret, token, label, env)
 | 
				
			||||||
 | 
						except ValueError as e:
 | 
				
			||||||
 | 
							return (str(e), 400)
 | 
				
			||||||
 | 
						return "OK"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@app.route('/mfa/disable', methods=['POST'])
 | 
				
			||||||
 | 
					@authorized_personnel_only
 | 
				
			||||||
 | 
					def totp_post_disable():
 | 
				
			||||||
 | 
						# Anyone accessing this route is an admin, and we permit them to
 | 
				
			||||||
 | 
						# disable the MFA status for any user if they submit a 'user' form
 | 
				
			||||||
 | 
						# field.
 | 
				
			||||||
 | 
						email = request.form.get('user', request.user_email) # user field if given, otherwise the user making the request
 | 
				
			||||||
 | 
						try:
 | 
				
			||||||
 | 
							result = disable_mfa(email, request.form.get('mfa-id') or None, env) # convert empty string to None
 | 
				
			||||||
 | 
						except ValueError as e:
 | 
				
			||||||
 | 
							return (str(e), 400)
 | 
				
			||||||
 | 
						if result: # success
 | 
				
			||||||
 | 
							return "OK"
 | 
				
			||||||
 | 
						else: # error
 | 
				
			||||||
 | 
							return ("Invalid user or MFA id.", 400)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# WEB
 | 
					# WEB
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -608,7 +608,6 @@ def validate_password(pw):
 | 
				
			|||||||
	if len(pw) < 8:
 | 
						if len(pw) < 8:
 | 
				
			||||||
		raise ValueError("Passwords must be at least eight characters.")
 | 
							raise ValueError("Passwords must be at least eight characters.")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 | 
				
			||||||
if __name__ == "__main__":
 | 
					if __name__ == "__main__":
 | 
				
			||||||
	import sys
 | 
						import sys
 | 
				
			||||||
	if len(sys.argv) > 2 and sys.argv[1] == "validate-email":
 | 
						if len(sys.argv) > 2 and sys.argv[1] == "validate-email":
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										141
									
								
								management/mfa.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								management/mfa.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,141 @@
 | 
				
			|||||||
 | 
					import base64
 | 
				
			||||||
 | 
					import hmac
 | 
				
			||||||
 | 
					import io
 | 
				
			||||||
 | 
					import os
 | 
				
			||||||
 | 
					import pyotp
 | 
				
			||||||
 | 
					import qrcode
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from mailconfig import open_database
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_user_id(email, c):
 | 
				
			||||||
 | 
						c.execute('SELECT id FROM users WHERE email=?', (email,))
 | 
				
			||||||
 | 
						r = c.fetchone()
 | 
				
			||||||
 | 
						if not r: raise ValueError("User does not exist.")
 | 
				
			||||||
 | 
						return r[0]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_mfa_state(email, env):
 | 
				
			||||||
 | 
						c = open_database(env)
 | 
				
			||||||
 | 
						c.execute('SELECT id, type, secret, mru_token, label FROM mfa WHERE user_id=?', (get_user_id(email, c),))
 | 
				
			||||||
 | 
						return [
 | 
				
			||||||
 | 
							{ "id": r[0], "type": r[1], "secret": r[2], "mru_token": r[3], "label": r[4] }
 | 
				
			||||||
 | 
							for r in c.fetchall()
 | 
				
			||||||
 | 
						]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_public_mfa_state(email, env):
 | 
				
			||||||
 | 
						mfa_state = get_mfa_state(email, env)
 | 
				
			||||||
 | 
						return [
 | 
				
			||||||
 | 
							{ "id": s["id"], "type": s["type"], "label": s["label"] }
 | 
				
			||||||
 | 
							for s in mfa_state
 | 
				
			||||||
 | 
						]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_hash_mfa_state(email, env):
 | 
				
			||||||
 | 
						mfa_state = get_mfa_state(email, env)
 | 
				
			||||||
 | 
						return [
 | 
				
			||||||
 | 
							{ "id": s["id"], "type": s["type"], "secret": s["secret"] }
 | 
				
			||||||
 | 
							for s in mfa_state
 | 
				
			||||||
 | 
						]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def enable_mfa(email, type, secret, token, label, env):
 | 
				
			||||||
 | 
						if type == "totp":
 | 
				
			||||||
 | 
							validate_totp_secret(secret)
 | 
				
			||||||
 | 
							# Sanity check with the provide current token.
 | 
				
			||||||
 | 
							totp = pyotp.TOTP(secret)
 | 
				
			||||||
 | 
							if not totp.verify(token, valid_window=1):
 | 
				
			||||||
 | 
								raise ValueError("Invalid token.")
 | 
				
			||||||
 | 
						else:
 | 
				
			||||||
 | 
							raise ValueError("Invalid MFA type.")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						conn, c = open_database(env, with_connection=True)
 | 
				
			||||||
 | 
						c.execute('INSERT INTO mfa (user_id, type, secret, label) VALUES (?, ?, ?, ?)', (get_user_id(email, c), type, secret, label))
 | 
				
			||||||
 | 
						conn.commit()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def set_mru_token(email, mfa_id, token, env):
 | 
				
			||||||
 | 
						conn, c = open_database(env, with_connection=True)
 | 
				
			||||||
 | 
						c.execute('UPDATE mfa SET mru_token=? WHERE user_id=? AND id=?', (token, get_user_id(email, c), mfa_id))
 | 
				
			||||||
 | 
						conn.commit()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def disable_mfa(email, mfa_id, env):
 | 
				
			||||||
 | 
						conn, c = open_database(env, with_connection=True)
 | 
				
			||||||
 | 
						if mfa_id is None:
 | 
				
			||||||
 | 
							# Disable all MFA for a user.
 | 
				
			||||||
 | 
							c.execute('DELETE FROM mfa WHERE user_id=?', (get_user_id(email, c),))
 | 
				
			||||||
 | 
						else:
 | 
				
			||||||
 | 
							# Disable a particular MFA mode for a user.
 | 
				
			||||||
 | 
							c.execute('DELETE FROM mfa WHERE user_id=? AND id=?', (get_user_id(email, c), mfa_id))
 | 
				
			||||||
 | 
						conn.commit()
 | 
				
			||||||
 | 
						return c.rowcount > 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def validate_totp_secret(secret):
 | 
				
			||||||
 | 
						if type(secret) != str or secret.strip() == "":
 | 
				
			||||||
 | 
							raise ValueError("No secret provided.")
 | 
				
			||||||
 | 
						if len(secret) != 32:
 | 
				
			||||||
 | 
							raise ValueError("Secret should be a 32 characters base32 string")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def provision_totp(email, env):
 | 
				
			||||||
 | 
						# Make a new secret.
 | 
				
			||||||
 | 
						secret = base64.b32encode(os.urandom(20)).decode('utf-8')
 | 
				
			||||||
 | 
						validate_totp_secret(secret) # sanity check
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						# Make a URI that we encode within a QR code.
 | 
				
			||||||
 | 
						uri = pyotp.TOTP(secret).provisioning_uri(
 | 
				
			||||||
 | 
							name=email,
 | 
				
			||||||
 | 
							issuer_name=env["PRIMARY_HOSTNAME"] + " Mail-in-a-Box Control Panel"
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						# Generate a QR code as a base64-encode PNG image.
 | 
				
			||||||
 | 
						qr = qrcode.make(uri)
 | 
				
			||||||
 | 
						byte_arr = io.BytesIO()
 | 
				
			||||||
 | 
						qr.save(byte_arr, format='PNG')
 | 
				
			||||||
 | 
						png_b64 = base64.b64encode(byte_arr.getvalue()).decode('utf-8')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return {
 | 
				
			||||||
 | 
							"type": "totp",
 | 
				
			||||||
 | 
							"secret": secret,
 | 
				
			||||||
 | 
							"qr_code_base64": png_b64
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def validate_auth_mfa(email, request, env):
 | 
				
			||||||
 | 
						# Validates that a login request satisfies any MFA modes
 | 
				
			||||||
 | 
						# that have been enabled for the user's account. Returns
 | 
				
			||||||
 | 
						# a tuple (status, [hints]). status is True for a successful
 | 
				
			||||||
 | 
						# MFA login, False for a missing token. If status is False,
 | 
				
			||||||
 | 
						# hints is an array of codes that indicate what the user
 | 
				
			||||||
 | 
						# can try. Possible codes are:
 | 
				
			||||||
 | 
						# "missing-totp-token"
 | 
				
			||||||
 | 
						# "invalid-totp-token"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						mfa_state = get_mfa_state(email, env)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						# If no MFA modes are added, return True.
 | 
				
			||||||
 | 
						if len(mfa_state) == 0:
 | 
				
			||||||
 | 
							return (True, [])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						# Try the enabled MFA modes.
 | 
				
			||||||
 | 
						hints = set()
 | 
				
			||||||
 | 
						for mfa_mode in mfa_state:
 | 
				
			||||||
 | 
							if mfa_mode["type"] == "totp":
 | 
				
			||||||
 | 
								# Check that a token is present in the X-Auth-Token header.
 | 
				
			||||||
 | 
								# If not, give a hint that one can be supplied.
 | 
				
			||||||
 | 
								token = request.headers.get('x-auth-token')
 | 
				
			||||||
 | 
								if not token:
 | 
				
			||||||
 | 
									hints.add("missing-totp-token")
 | 
				
			||||||
 | 
									continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								# Check for a replay attack.
 | 
				
			||||||
 | 
								if hmac.compare_digest(token, mfa_mode['mru_token'] or ""):
 | 
				
			||||||
 | 
									# If the token fails, skip this MFA mode.
 | 
				
			||||||
 | 
									hints.add("invalid-totp-token")
 | 
				
			||||||
 | 
									continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								# Check the token.
 | 
				
			||||||
 | 
								totp = pyotp.TOTP(mfa_mode["secret"])
 | 
				
			||||||
 | 
								if not totp.verify(token, valid_window=1):
 | 
				
			||||||
 | 
									hints.add("invalid-totp-token")
 | 
				
			||||||
 | 
									continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								# On success, record the token to prevent a replay attack.
 | 
				
			||||||
 | 
								set_mru_token(email, mfa_mode['id'], token, env)
 | 
				
			||||||
 | 
								return (True, [])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						# On a failed login, indicate failure and any hints for what the user can do instead.
 | 
				
			||||||
 | 
						return (False, list(hints))
 | 
				
			||||||
@ -97,11 +97,14 @@
 | 
				
			|||||||
              </ul>
 | 
					              </ul>
 | 
				
			||||||
            </li>
 | 
					            </li>
 | 
				
			||||||
            <li class="dropdown">
 | 
					            <li class="dropdown">
 | 
				
			||||||
              <a href="#" class="dropdown-toggle" data-toggle="dropdown">Mail <b class="caret"></b></a>
 | 
					              <a href="#" class="dropdown-toggle" data-toggle="dropdown">Mail & Users <b class="caret"></b></a>
 | 
				
			||||||
              <ul class="dropdown-menu">
 | 
					              <ul class="dropdown-menu">
 | 
				
			||||||
                <li><a href="#mail-guide" onclick="return show_panel(this);">Instructions</a></li>
 | 
					                <li><a href="#mail-guide" onclick="return show_panel(this);">Instructions</a></li>
 | 
				
			||||||
                <li><a href="#users" onclick="return show_panel(this);">Users</a></li>
 | 
					                <li><a href="#users" onclick="return show_panel(this);">Users</a></li>
 | 
				
			||||||
                <li><a href="#aliases" onclick="return show_panel(this);">Aliases</a></li>
 | 
					                <li><a href="#aliases" onclick="return show_panel(this);">Aliases</a></li>
 | 
				
			||||||
 | 
					                <li class="divider"></li>
 | 
				
			||||||
 | 
					                <li class="dropdown-header">Your Account</li>
 | 
				
			||||||
 | 
					                <li><a href="#mfa" onclick="return show_panel(this);">Two-Factor Authentication</a></li>
 | 
				
			||||||
              </ul>
 | 
					              </ul>
 | 
				
			||||||
            </li>
 | 
					            </li>
 | 
				
			||||||
            <li><a href="#sync_guide" onclick="return show_panel(this);">Contacts/Calendar</a></li>
 | 
					            <li><a href="#sync_guide" onclick="return show_panel(this);">Contacts/Calendar</a></li>
 | 
				
			||||||
@ -131,6 +134,10 @@
 | 
				
			|||||||
      {% include "custom-dns.html" %}
 | 
					      {% include "custom-dns.html" %}
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <div id="panel_mfa" class="admin_panel">
 | 
				
			||||||
 | 
					      {% include "mfa.html" %}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <div id="panel_login" class="admin_panel">
 | 
					      <div id="panel_login" class="admin_panel">
 | 
				
			||||||
      {% include "login.html" %}
 | 
					      {% include "login.html" %}
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
@ -292,7 +299,7 @@ function ajax_with_indicator(options) {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
var api_credentials = ["", ""];
 | 
					var api_credentials = ["", ""];
 | 
				
			||||||
function api(url, method, data, callback, callback_error) {
 | 
					function api(url, method, data, callback, callback_error, headers) {
 | 
				
			||||||
  // from http://www.webtoolkit.info/javascript-base64.html
 | 
					  // from http://www.webtoolkit.info/javascript-base64.html
 | 
				
			||||||
  function base64encode(input) {
 | 
					  function base64encode(input) {
 | 
				
			||||||
    _keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
 | 
					    _keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
 | 
				
			||||||
@ -330,7 +337,7 @@ function api(url, method, data, callback, callback_error) {
 | 
				
			|||||||
    method: method,
 | 
					    method: method,
 | 
				
			||||||
    cache: false,
 | 
					    cache: false,
 | 
				
			||||||
    data: data,
 | 
					    data: data,
 | 
				
			||||||
 | 
					    headers: headers,
 | 
				
			||||||
    // the custom DNS api sends raw POST/PUT bodies --- prevent URL-encoding
 | 
					    // the custom DNS api sends raw POST/PUT bodies --- prevent URL-encoding
 | 
				
			||||||
    processData: typeof data != "string",
 | 
					    processData: typeof data != "string",
 | 
				
			||||||
    mimeType: typeof data == "string" ? "text/plain; charset=ascii" : null,
 | 
					    mimeType: typeof data == "string" ? "text/plain; charset=ascii" : null,
 | 
				
			||||||
@ -358,6 +365,16 @@ function api(url, method, data, callback, callback_error) {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
var current_panel = null;
 | 
					var current_panel = null;
 | 
				
			||||||
var switch_back_to_panel = null;
 | 
					var switch_back_to_panel = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function do_logout() {
 | 
				
			||||||
 | 
					  api_credentials = ["", ""];
 | 
				
			||||||
 | 
					  if (typeof localStorage != 'undefined')
 | 
				
			||||||
 | 
					    localStorage.removeItem("miab-cp-credentials");
 | 
				
			||||||
 | 
					  if (typeof sessionStorage != 'undefined')
 | 
				
			||||||
 | 
					    sessionStorage.removeItem("miab-cp-credentials");
 | 
				
			||||||
 | 
					  show_panel('login');
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function show_panel(panelid) {
 | 
					function show_panel(panelid) {
 | 
				
			||||||
  if (panelid.getAttribute)
 | 
					  if (panelid.getAttribute)
 | 
				
			||||||
    // we might be passed an HTMLElement <a>.
 | 
					    // we might be passed an HTMLElement <a>.
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,29 @@
 | 
				
			|||||||
<h1 style="margin: 1em; text-align: center">{{hostname}}</h1>
 | 
					<style>
 | 
				
			||||||
 | 
					  .title {
 | 
				
			||||||
 | 
					    margin: 1em;
 | 
				
			||||||
 | 
					    text-align: center;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  .subtitle {
 | 
				
			||||||
 | 
					    margin: 2em;
 | 
				
			||||||
 | 
					    text-align: center;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  .login {
 | 
				
			||||||
 | 
					    margin: 0 auto;
 | 
				
			||||||
 | 
					    max-width: 32em;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  .login #loginOtp {
 | 
				
			||||||
 | 
					    display: none;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  #loginForm.is-twofactor #loginOtp {
 | 
				
			||||||
 | 
					    display: block
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<h1 class="title">{{hostname}}</h1>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% if no_users_exist or no_admins_exist %}
 | 
					{% if no_users_exist or no_admins_exist %}
 | 
				
			||||||
<div class="row">
 | 
					<div class="row">
 | 
				
			||||||
@ -7,23 +32,23 @@
 | 
				
			|||||||
  <p class="text-danger">There are no users on this system! To make an administrative user,
 | 
					  <p class="text-danger">There are no users on this system! To make an administrative user,
 | 
				
			||||||
  log into this machine using SSH (like when you first set it up) and run:</p>
 | 
					  log into this machine using SSH (like when you first set it up) and run:</p>
 | 
				
			||||||
  <pre>cd mailinabox
 | 
					  <pre>cd mailinabox
 | 
				
			||||||
sudo tools/mail.py user add me@{{hostname}}
 | 
					sudo management/cli.py user add me@{{hostname}}
 | 
				
			||||||
sudo tools/mail.py user make-admin me@{{hostname}}</pre>
 | 
					sudo management/cli.py user make-admin me@{{hostname}}</pre>
 | 
				
			||||||
  {% else %}
 | 
					  {% else %}
 | 
				
			||||||
  <p class="text-danger">There are no administrative users on this system! To make an administrative user,
 | 
					  <p class="text-danger">There are no administrative users on this system! To make an administrative user,
 | 
				
			||||||
  log into this machine using SSH (like when you first set it up) and run:</p>
 | 
					  log into this machine using SSH (like when you first set it up) and run:</p>
 | 
				
			||||||
  <pre>cd mailinabox
 | 
					  <pre>cd mailinabox
 | 
				
			||||||
sudo tools/mail.py user make-admin me@{{hostname}}</pre>
 | 
					sudo management/cli.py user make-admin me@{{hostname}}</pre>
 | 
				
			||||||
  {% endif %}
 | 
					  {% endif %}
 | 
				
			||||||
  <hr>
 | 
					  <hr>
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
{% endif %}
 | 
					{% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<p style="margin: 2em; text-align: center;">Log in here for your Mail-in-a-Box control panel.</p>
 | 
					<p class="subtitle">Log in here for your Mail-in-a-Box control panel.</p>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<div style="margin: 0 auto; max-width: 32em;">
 | 
					<div class="login">
 | 
				
			||||||
  <form class="form-horizontal" role="form" onsubmit="do_login(); return false;" method="get">
 | 
					  <form id="loginForm" class="form-horizontal" role="form" onsubmit="do_login(); return false;" method="get">
 | 
				
			||||||
    <div class="form-group">
 | 
					    <div class="form-group">
 | 
				
			||||||
      <label for="inputEmail3" class="col-sm-3 control-label">Email</label>
 | 
					      <label for="inputEmail3" class="col-sm-3 control-label">Email</label>
 | 
				
			||||||
      <div class="col-sm-9">
 | 
					      <div class="col-sm-9">
 | 
				
			||||||
@ -36,6 +61,13 @@ sudo tools/mail.py user make-admin me@{{hostname}}</pre>
 | 
				
			|||||||
        <input name="password" type="password" class="form-control" id="loginPassword" placeholder="Password">
 | 
					        <input name="password" type="password" class="form-control" id="loginPassword" placeholder="Password">
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 | 
					    <div class="form-group" id="loginOtp">
 | 
				
			||||||
 | 
					      <label for="loginOtpInput" class="col-sm-3 control-label">Code</label>
 | 
				
			||||||
 | 
					      <div class="col-sm-9">
 | 
				
			||||||
 | 
					          <input type="text" class="form-control" id="loginOtpInput" placeholder="6-digit code">
 | 
				
			||||||
 | 
					          <div class="help-block" style="margin-top: 5px; font-size: 90%">Enter the six-digit code generated by your two factor authentication app.</div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
    <div class="form-group">
 | 
					    <div class="form-group">
 | 
				
			||||||
      <div class="col-sm-offset-3 col-sm-9">
 | 
					      <div class="col-sm-offset-3 col-sm-9">
 | 
				
			||||||
        <div class="checkbox">
 | 
					        <div class="checkbox">
 | 
				
			||||||
@ -53,15 +85,15 @@ sudo tools/mail.py user make-admin me@{{hostname}}</pre>
 | 
				
			|||||||
  </form>
 | 
					  </form>
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 | 
				
			||||||
<script>
 | 
					<script>
 | 
				
			||||||
function do_login() {
 | 
					function do_login() {
 | 
				
			||||||
  if ($('#loginEmail').val() == "") {
 | 
					  if ($('#loginEmail').val() == "") {
 | 
				
			||||||
    show_modal_error("Login Failed", "Enter your email address.", function() {
 | 
					    show_modal_error("Login Failed", "Enter your email address.", function() {
 | 
				
			||||||
	$('#loginEmail').focus();
 | 
					      $('#loginEmail').focus();
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
    return false;
 | 
					    return false;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if ($('#loginPassword').val() == "") {
 | 
					  if ($('#loginPassword').val() == "") {
 | 
				
			||||||
    show_modal_error("Login Failed", "Enter your email password.", function() {
 | 
					    show_modal_error("Login Failed", "Enter your email password.", function() {
 | 
				
			||||||
        $('#loginPassword').focus();
 | 
					        $('#loginPassword').focus();
 | 
				
			||||||
@ -75,17 +107,29 @@ function do_login() {
 | 
				
			|||||||
  api(
 | 
					  api(
 | 
				
			||||||
  "/me",
 | 
					  "/me",
 | 
				
			||||||
  "GET",
 | 
					  "GET",
 | 
				
			||||||
  { },
 | 
					  {},
 | 
				
			||||||
  function(response){
 | 
					  function(response) {
 | 
				
			||||||
    // This API call always succeeds. It returns a JSON object indicating
 | 
					    // This API call always succeeds. It returns a JSON object indicating
 | 
				
			||||||
    // whether the request was authenticated or not.
 | 
					    // whether the request was authenticated or not.
 | 
				
			||||||
    if (response.status != "ok") {
 | 
					    if (response.status != 'ok') {
 | 
				
			||||||
      // Show why the login failed.
 | 
					      if (response.status === 'missing-totp-token' || (response.status === 'invalid' && response.reason == 'invalid-totp-token')) {
 | 
				
			||||||
      show_modal_error("Login Failed", response.reason)
 | 
					        $('#loginForm').addClass('is-twofactor');
 | 
				
			||||||
 | 
					        if (response.reason === "invalid-totp-token") {
 | 
				
			||||||
 | 
					          show_modal_error("Login Failed", "Incorrect two factor authentication token.");
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					          setTimeout(() => {
 | 
				
			||||||
 | 
					              $('#loginOtpInput').focus();
 | 
				
			||||||
 | 
					          });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        $('#loginForm').removeClass('is-twofactor');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // Reset any saved credentials.
 | 
					        // Show why the login failed.
 | 
				
			||||||
      do_logout();
 | 
					        show_modal_error("Login Failed", response.reason)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Reset any saved credentials.
 | 
				
			||||||
 | 
					        do_logout();
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
    } else if (!("api_key" in response)) {
 | 
					    } else if (!("api_key" in response)) {
 | 
				
			||||||
      // Login succeeded but user might not be authorized!
 | 
					      // Login succeeded but user might not be authorized!
 | 
				
			||||||
      show_modal_error("Login Failed", "You are not an administrator on this system.")
 | 
					      show_modal_error("Login Failed", "You are not an administrator on this system.")
 | 
				
			||||||
@ -102,6 +146,8 @@ function do_login() {
 | 
				
			|||||||
      // Try to wipe the username/password information.
 | 
					      // Try to wipe the username/password information.
 | 
				
			||||||
      $('#loginEmail').val('');
 | 
					      $('#loginEmail').val('');
 | 
				
			||||||
      $('#loginPassword').val('');
 | 
					      $('#loginPassword').val('');
 | 
				
			||||||
 | 
					      $('#loginOtpInput').val('');
 | 
				
			||||||
 | 
					      $('#loginForm').removeClass('is-twofactor');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // Remember the credentials.
 | 
					      // Remember the credentials.
 | 
				
			||||||
      if (typeof localStorage != 'undefined' && typeof sessionStorage != 'undefined') {
 | 
					      if (typeof localStorage != 'undefined' && typeof sessionStorage != 'undefined') {
 | 
				
			||||||
@ -119,19 +165,16 @@ function do_login() {
 | 
				
			|||||||
      // which confuses the loading indicator.
 | 
					      // which confuses the loading indicator.
 | 
				
			||||||
      setTimeout(function() { show_panel(!switch_back_to_panel || switch_back_to_panel == "login" ? 'system_status' : switch_back_to_panel) }, 300);
 | 
					      setTimeout(function() { show_panel(!switch_back_to_panel || switch_back_to_panel == "login" ? 'system_status' : switch_back_to_panel) }, 300);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  })
 | 
					  },
 | 
				
			||||||
}
 | 
					  undefined,
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
function do_logout() {
 | 
					    'x-auth-token': $('#loginOtpInput').val()
 | 
				
			||||||
  api_credentials = ["", ""];
 | 
					  });
 | 
				
			||||||
  if (typeof localStorage != 'undefined')
 | 
					 | 
				
			||||||
    localStorage.removeItem("miab-cp-credentials");
 | 
					 | 
				
			||||||
  if (typeof sessionStorage != 'undefined')
 | 
					 | 
				
			||||||
    sessionStorage.removeItem("miab-cp-credentials");
 | 
					 | 
				
			||||||
  show_panel('login');
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function show_login() {
 | 
					function show_login() {
 | 
				
			||||||
 | 
					  $('#loginForm').removeClass('is-twofactor');
 | 
				
			||||||
 | 
					  $('#loginOtpInput').val('');
 | 
				
			||||||
  $('#loginEmail,#loginPassword').each(function() {
 | 
					  $('#loginEmail,#loginPassword').each(function() {
 | 
				
			||||||
    var input = $(this);
 | 
					    var input = $(this);
 | 
				
			||||||
    if (!$.trim(input.val())) {
 | 
					    if (!$.trim(input.val())) {
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										242
									
								
								management/templates/mfa.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										242
									
								
								management/templates/mfa.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,242 @@
 | 
				
			|||||||
 | 
					<style>
 | 
				
			||||||
 | 
					    .twofactor #totp-setup,
 | 
				
			||||||
 | 
					    .twofactor #disable-2fa,
 | 
				
			||||||
 | 
					    .twofactor #output-2fa {
 | 
				
			||||||
 | 
					        display: none;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    .twofactor.loaded .loading-indicator {
 | 
				
			||||||
 | 
					        display: none;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    .twofactor.disabled #disable-2fa,
 | 
				
			||||||
 | 
					    .twofactor.enabled #totp-setup {
 | 
				
			||||||
 | 
					        display: none;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    .twofactor.disabled #totp-setup,
 | 
				
			||||||
 | 
					    .twofactor.enabled #disable-2fa {
 | 
				
			||||||
 | 
					        display: block;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    .twofactor #totp-setup-qr img {
 | 
				
			||||||
 | 
					        display: block;
 | 
				
			||||||
 | 
					        width: 256px;
 | 
				
			||||||
 | 
					        max-width: 100%;
 | 
				
			||||||
 | 
					        height: auto;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    .twofactor #output-2fa.visible {
 | 
				
			||||||
 | 
					        display: block;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<h2>Two-Factor Authentication</h2>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<p>When two-factor authentication is enabled, you will be prompted to enter a six digit code from an
 | 
				
			||||||
 | 
					authenticator app (usually on your phone) when you log into this control panel.</p>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<div class="panel panel-danger">
 | 
				
			||||||
 | 
					<div class="panel-heading">
 | 
				
			||||||
 | 
					Enabling two-factor authentication does not protect access to your email
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					<div class="panel-body">
 | 
				
			||||||
 | 
					Enabling two-factor authentication on this page only limits access to this control panel. Remember that most websites allow you to
 | 
				
			||||||
 | 
					reset your password by checking your email, so anyone with access to your email can typically take over
 | 
				
			||||||
 | 
					your other accounts. Additionally, if your email address or any alias that forwards to your email
 | 
				
			||||||
 | 
					address is a typical domain control validation address (e.g admin@, administrator@, postmaster@, hostmaster@,
 | 
				
			||||||
 | 
					webmaster@, abuse@), extra care should be taken to protect the account. <strong>Always use a strong password,
 | 
				
			||||||
 | 
					and ensure every administrator account for this control panel does the same.</strong>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<div class="twofactor">
 | 
				
			||||||
 | 
					    <div class="loading-indicator">Loading...</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <form id="totp-setup">
 | 
				
			||||||
 | 
					        <h3>Setup Instructions</h3>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <div class="form-group">
 | 
				
			||||||
 | 
					            <p>1. Install <a href="https://freeotp.github.io/">FreeOTP</a> or <a href="https://www.pcworld.com/article/3225913/what-is-two-factor-authentication-and-which-2fa-apps-are-best.html">any
 | 
				
			||||||
 | 
					            other two-factor authentication app</a> that supports TOTP.</p>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <div class="form-group">
 | 
				
			||||||
 | 
					            <p style="margin-bottom: 0">2. Scan the QR code in the app or directly enter the secret into the app:</p>
 | 
				
			||||||
 | 
					            <div id="totp-setup-qr"></div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <div class="form-group">
 | 
				
			||||||
 | 
					            <label for="otp-label" style="font-weight: normal">3. Optionally, give your device a label so that you can remember what device you set it up on:</label>
 | 
				
			||||||
 | 
					            <input type="text" id="totp-setup-label" class="form-control" placeholder="my phone" />
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <div class="form-group">
 | 
				
			||||||
 | 
					            <label for="otp" style="font-weight: normal">4. Use the app to generate your first six-digit code and enter it here:</label>
 | 
				
			||||||
 | 
					            <input type="text" id="totp-setup-token" class="form-control" placeholder="6-digit code" />
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <input type="hidden" id="totp-setup-secret" />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <div class="form-group">
 | 
				
			||||||
 | 
					            <p>When you click Enable Two-Factor Authentication, you will be logged out of the control panel and will have to log in
 | 
				
			||||||
 | 
					            again, now using your two-factor authentication app.</p>
 | 
				
			||||||
 | 
					            <button id="totp-setup-submit" disabled type="submit" class="btn">Enable Two-Factor Authentication</button>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					    </form>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <form id="disable-2fa">
 | 
				
			||||||
 | 
					        <div class="form-group">
 | 
				
			||||||
 | 
					            <p>Two-factor authentication is active for your account<span id="mfa-device-label"></span>.</p>
 | 
				
			||||||
 | 
					            <p>You will have to log into the admin panel again after disabling two-factor authentication.</p>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div class="form-group">
 | 
				
			||||||
 | 
					            <button type="submit" class="btn btn-danger">Disable Two-Factor Authentication</button>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					    </form>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <div id="output-2fa" class="panel panel-danger">
 | 
				
			||||||
 | 
					        <div class="panel-body"></div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script>
 | 
				
			||||||
 | 
					    var el = {
 | 
				
			||||||
 | 
					        disableForm: document.getElementById('disable-2fa'),
 | 
				
			||||||
 | 
					        output: document.getElementById('output-2fa'),
 | 
				
			||||||
 | 
					        totpSetupForm: document.getElementById('totp-setup'),
 | 
				
			||||||
 | 
					        totpSetupToken: document.getElementById('totp-setup-token'),
 | 
				
			||||||
 | 
					        totpSetupSecret: document.getElementById('totp-setup-secret'),
 | 
				
			||||||
 | 
					        totpSetupLabel: document.getElementById('totp-setup-label'),
 | 
				
			||||||
 | 
					        totpQr: document.getElementById('totp-setup-qr'),
 | 
				
			||||||
 | 
					        totpSetupSubmit: document.querySelector('#totp-setup-submit'),
 | 
				
			||||||
 | 
					        wrapper: document.querySelector('.twofactor')
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    function update_setup_disabled(evt) {
 | 
				
			||||||
 | 
					        var val = evt.target.value.trim();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (
 | 
				
			||||||
 | 
					            typeof val !== 'string' ||
 | 
				
			||||||
 | 
					            typeof el.totpSetupSecret.value !== 'string' ||
 | 
				
			||||||
 | 
					            val.length !== 6 ||
 | 
				
			||||||
 | 
					            el.totpSetupSecret.value.length !== 32 ||
 | 
				
			||||||
 | 
					            !(/^\+?\d+$/.test(val))
 | 
				
			||||||
 | 
					        ) {
 | 
				
			||||||
 | 
					            el.totpSetupSubmit.setAttribute('disabled', '');
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            el.totpSetupSubmit.removeAttribute('disabled');
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    function render_totp_setup(provisioned_totp) {
 | 
				
			||||||
 | 
					        var img = document.createElement('img');
 | 
				
			||||||
 | 
					        img.src = "data:image/png;base64," + provisioned_totp.qr_code_base64;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var code = document.createElement('div');
 | 
				
			||||||
 | 
					        code.innerHTML = `Secret: ${provisioned_totp.secret}`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        el.totpQr.appendChild(img);
 | 
				
			||||||
 | 
					        el.totpQr.appendChild(code);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        el.totpSetupToken.addEventListener('input', update_setup_disabled);
 | 
				
			||||||
 | 
					        el.totpSetupForm.addEventListener('submit', do_enable_totp);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        el.totpSetupSecret.setAttribute('value', provisioned_totp.secret);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        el.wrapper.classList.add('disabled');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    function render_disable(mfa) {
 | 
				
			||||||
 | 
					        el.disableForm.addEventListener('submit', do_disable);
 | 
				
			||||||
 | 
					        el.wrapper.classList.add('enabled');
 | 
				
			||||||
 | 
					        if (mfa.label)
 | 
				
			||||||
 | 
					          $("#mfa-device-label").text(" on device '" + mfa.label + "'");
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    function hide_error() {
 | 
				
			||||||
 | 
					        el.output.querySelector('.panel-body').innerHTML = '';
 | 
				
			||||||
 | 
					        el.output.classList.remove('visible');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    function render_error(msg) {
 | 
				
			||||||
 | 
					        el.output.querySelector('.panel-body').innerHTML = msg;
 | 
				
			||||||
 | 
					        el.output.classList.add('visible');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    function reset_view() {
 | 
				
			||||||
 | 
					        el.wrapper.classList.remove('loaded', 'disabled', 'enabled');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        el.disableForm.removeEventListener('submit', do_disable);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        hide_error();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        el.totpSetupForm.reset();
 | 
				
			||||||
 | 
					        el.totpSetupForm.removeEventListener('submit', do_enable_totp);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        el.totpSetupSecret.setAttribute('value', '');
 | 
				
			||||||
 | 
					        el.totpSetupToken.removeEventListener('input', update_setup_disabled);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        el.totpSetupSubmit.setAttribute('disabled', '');
 | 
				
			||||||
 | 
					        el.totpQr.innerHTML = '';
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    function show_mfa() {
 | 
				
			||||||
 | 
					        reset_view();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        api(
 | 
				
			||||||
 | 
					            '/mfa/status',
 | 
				
			||||||
 | 
					            'POST',
 | 
				
			||||||
 | 
					            {},
 | 
				
			||||||
 | 
					            function(res) {
 | 
				
			||||||
 | 
					                el.wrapper.classList.add('loaded');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                var has_mfa = false;
 | 
				
			||||||
 | 
					                res.enabled_mfa.forEach(function(mfa) {
 | 
				
			||||||
 | 
					                    if (mfa.type == "totp") {
 | 
				
			||||||
 | 
					                        render_disable(mfa);
 | 
				
			||||||
 | 
					                        has_mfa = true;
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                if (!has_mfa)
 | 
				
			||||||
 | 
					                  render_totp_setup(res.new_mfa.totp);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    function do_disable(evt) {
 | 
				
			||||||
 | 
					        evt.preventDefault();
 | 
				
			||||||
 | 
					        hide_error();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        api(
 | 
				
			||||||
 | 
					            '/mfa/disable',
 | 
				
			||||||
 | 
					            'POST',
 | 
				
			||||||
 | 
					            { type: 'totp' },
 | 
				
			||||||
 | 
					            function() {
 | 
				
			||||||
 | 
					                do_logout();
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return false;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    function do_enable_totp(evt) {
 | 
				
			||||||
 | 
					        evt.preventDefault();
 | 
				
			||||||
 | 
					        hide_error();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        api(
 | 
				
			||||||
 | 
					            '/mfa/totp/enable',
 | 
				
			||||||
 | 
					            'POST',
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                token: $(el.totpSetupToken).val(),
 | 
				
			||||||
 | 
					                secret: $(el.totpSetupSecret).val(),
 | 
				
			||||||
 | 
					                label: $(el.totpSetupLabel).val()
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            function(res) { do_logout(); },
 | 
				
			||||||
 | 
					            function(res) { render_error(res); }
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return false;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
@ -1,6 +1,6 @@
 | 
				
			|||||||
# If there aren't any mail users yet, create one.
 | 
					# If there aren't any mail users yet, create one.
 | 
				
			||||||
if [ -z "`tools/mail.py user`" ]; then
 | 
					if [ -z "`management/cli.py user`" ]; then
 | 
				
			||||||
	# The outut of "tools/mail.py user" is a list of mail users. If there
 | 
						# The outut of "management/cli.py user" is a list of mail users. If there
 | 
				
			||||||
	# aren't any yet, it'll be empty.
 | 
						# aren't any yet, it'll be empty.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	# If we didn't ask for an email address at the start, do so now.
 | 
						# If we didn't ask for an email address at the start, do so now.
 | 
				
			||||||
@ -47,11 +47,11 @@ if [ -z "`tools/mail.py user`" ]; then
 | 
				
			|||||||
	fi
 | 
						fi
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	# Create the user's mail account. This will ask for a password if none was given above.
 | 
						# Create the user's mail account. This will ask for a password if none was given above.
 | 
				
			||||||
	tools/mail.py user add $EMAIL_ADDR ${EMAIL_PW:-}
 | 
						management/cli.py user add $EMAIL_ADDR ${EMAIL_PW:-}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	# Make it an admin.
 | 
						# Make it an admin.
 | 
				
			||||||
	hide_output tools/mail.py user make-admin $EMAIL_ADDR
 | 
						hide_output management/cli.py user make-admin $EMAIL_ADDR
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	# Create an alias to which we'll direct all automatically-created administrative aliases.
 | 
						# Create an alias to which we'll direct all automatically-created administrative aliases.
 | 
				
			||||||
	tools/mail.py alias add administrator@$PRIMARY_HOSTNAME $EMAIL_ADDR > /dev/null
 | 
						management/cli.py alias add administrator@$PRIMARY_HOSTNAME $EMAIL_ADDR > /dev/null
 | 
				
			||||||
fi
 | 
					fi
 | 
				
			||||||
 | 
				
			|||||||
@ -22,6 +22,7 @@ if [ ! -f $db_path ]; then
 | 
				
			|||||||
	echo Creating new user database: $db_path;
 | 
						echo Creating new user database: $db_path;
 | 
				
			||||||
	echo "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, extra, privileges TEXT NOT NULL DEFAULT '');" | sqlite3 $db_path;
 | 
						echo "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, extra, privileges TEXT NOT NULL DEFAULT '');" | sqlite3 $db_path;
 | 
				
			||||||
	echo "CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);" | sqlite3 $db_path;
 | 
						echo "CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);" | sqlite3 $db_path;
 | 
				
			||||||
 | 
						echo "CREATE TABLE mfa (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, type TEXT NOT NULL, secret TEXT NOT NULL, mru_token TEXT, label TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);" | sqlite3 $db_path;
 | 
				
			||||||
fi
 | 
					fi
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# ### User Authentication
 | 
					# ### User Authentication
 | 
				
			||||||
 | 
				
			|||||||
@ -50,6 +50,7 @@ hide_output $venv/bin/pip install --upgrade pip
 | 
				
			|||||||
hide_output $venv/bin/pip install --upgrade \
 | 
					hide_output $venv/bin/pip install --upgrade \
 | 
				
			||||||
	rtyaml "email_validator>=1.0.0" "exclusiveprocess" \
 | 
						rtyaml "email_validator>=1.0.0" "exclusiveprocess" \
 | 
				
			||||||
	flask dnspython python-dateutil \
 | 
						flask dnspython python-dateutil \
 | 
				
			||||||
 | 
					    qrcode[pil] pyotp \
 | 
				
			||||||
	"idna>=2.0.0" "cryptography==2.2.2" boto psutil postfix-mta-sts-resolver
 | 
						"idna>=2.0.0" "cryptography==2.2.2" boto psutil postfix-mta-sts-resolver
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# CONFIGURATION
 | 
					# CONFIGURATION
 | 
				
			||||||
 | 
				
			|||||||
@ -181,6 +181,12 @@ def migration_12(env):
 | 
				
			|||||||
            conn.commit()
 | 
					            conn.commit()
 | 
				
			||||||
            conn.close()
 | 
					            conn.close()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def migration_13(env):
 | 
				
			||||||
 | 
						# Add the "mfa" table for configuring MFA for login to the control panel.
 | 
				
			||||||
 | 
						db = os.path.join(env["STORAGE_ROOT"], 'mail/users.sqlite')
 | 
				
			||||||
 | 
						shell("check_call", ["sqlite3", db, "CREATE TABLE mfa (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, type TEXT NOT NULL, secret TEXT NOT NULL, mru_token TEXT, label TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);"])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					###########################################################
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def get_current_migration():
 | 
					def get_current_migration():
 | 
				
			||||||
	ver = 0
 | 
						ver = 0
 | 
				
			||||||
 | 
				
			|||||||
@ -352,7 +352,7 @@ rm -f /etc/cron.hourly/mailinabox-owncloud
 | 
				
			|||||||
# and there's a lot they could mess up, so we don't make any users admins of Nextcloud.
 | 
					# and there's a lot they could mess up, so we don't make any users admins of Nextcloud.
 | 
				
			||||||
# But if we wanted to, we would do this:
 | 
					# But if we wanted to, we would do this:
 | 
				
			||||||
# ```
 | 
					# ```
 | 
				
			||||||
# for user in $(tools/mail.py user admins); do
 | 
					# for user in $(management/cli.py user admins); do
 | 
				
			||||||
#	 sqlite3 $STORAGE_ROOT/owncloud/owncloud.db "INSERT OR IGNORE INTO oc_group_user VALUES ('admin', '$user')"
 | 
					#	 sqlite3 $STORAGE_ROOT/owncloud/owncloud.db "INSERT OR IGNORE INTO oc_group_user VALUES ('admin', '$user')"
 | 
				
			||||||
# done
 | 
					# done
 | 
				
			||||||
# ```
 | 
					# ```
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										131
									
								
								tools/mail.py
									
									
									
									
									
								
							
							
						
						
									
										131
									
								
								tools/mail.py
									
									
									
									
									
								
							@ -1,128 +1,3 @@
 | 
				
			|||||||
#!/usr/bin/python3
 | 
					#!/bin/bash
 | 
				
			||||||
 | 
					# This script has moved.
 | 
				
			||||||
import sys, getpass, urllib.request, urllib.error, json, re
 | 
					management/cli.py "$@"
 | 
				
			||||||
 | 
					 | 
				
			||||||
def mgmt(cmd, data=None, is_json=False):
 | 
					 | 
				
			||||||
	# The base URL for the management daemon. (Listens on IPv4 only.)
 | 
					 | 
				
			||||||
	mgmt_uri = 'http://127.0.0.1:10222'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	setup_key_auth(mgmt_uri)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	req = urllib.request.Request(mgmt_uri + cmd, urllib.parse.urlencode(data).encode("utf8") if data else None)
 | 
					 | 
				
			||||||
	try:
 | 
					 | 
				
			||||||
		response = urllib.request.urlopen(req)
 | 
					 | 
				
			||||||
	except urllib.error.HTTPError as e:
 | 
					 | 
				
			||||||
		if e.code == 401:
 | 
					 | 
				
			||||||
			try:
 | 
					 | 
				
			||||||
				print(e.read().decode("utf8"))
 | 
					 | 
				
			||||||
			except:
 | 
					 | 
				
			||||||
				pass
 | 
					 | 
				
			||||||
			print("The management daemon refused access. The API key file may be out of sync. Try 'service mailinabox restart'.", file=sys.stderr)
 | 
					 | 
				
			||||||
		elif hasattr(e, 'read'):
 | 
					 | 
				
			||||||
			print(e.read().decode('utf8'), file=sys.stderr)
 | 
					 | 
				
			||||||
		else:
 | 
					 | 
				
			||||||
			print(e, file=sys.stderr)
 | 
					 | 
				
			||||||
		sys.exit(1)
 | 
					 | 
				
			||||||
	resp = response.read().decode('utf8')
 | 
					 | 
				
			||||||
	if is_json: resp = json.loads(resp)
 | 
					 | 
				
			||||||
	return resp
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def read_password():
 | 
					 | 
				
			||||||
    while True:
 | 
					 | 
				
			||||||
        first = getpass.getpass('password: ')
 | 
					 | 
				
			||||||
        if len(first) < 8:
 | 
					 | 
				
			||||||
            print("Passwords must be at least eight characters.")
 | 
					 | 
				
			||||||
            continue
 | 
					 | 
				
			||||||
        second = getpass.getpass(' (again): ')
 | 
					 | 
				
			||||||
        if first != second:
 | 
					 | 
				
			||||||
            print("Passwords not the same. Try again.")
 | 
					 | 
				
			||||||
            continue
 | 
					 | 
				
			||||||
        break
 | 
					 | 
				
			||||||
    return first
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def setup_key_auth(mgmt_uri):
 | 
					 | 
				
			||||||
	key = open('/var/lib/mailinabox/api.key').read().strip()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	auth_handler = urllib.request.HTTPBasicAuthHandler()
 | 
					 | 
				
			||||||
	auth_handler.add_password(
 | 
					 | 
				
			||||||
		realm='Mail-in-a-Box Management Server',
 | 
					 | 
				
			||||||
		uri=mgmt_uri,
 | 
					 | 
				
			||||||
		user=key,
 | 
					 | 
				
			||||||
		passwd='')
 | 
					 | 
				
			||||||
	opener = urllib.request.build_opener(auth_handler)
 | 
					 | 
				
			||||||
	urllib.request.install_opener(opener)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
if len(sys.argv) < 2:
 | 
					 | 
				
			||||||
	print("Usage: ")
 | 
					 | 
				
			||||||
	print("  tools/mail.py user  (lists users)")
 | 
					 | 
				
			||||||
	print("  tools/mail.py user add user@domain.com [password]")
 | 
					 | 
				
			||||||
	print("  tools/mail.py user password user@domain.com [password]")
 | 
					 | 
				
			||||||
	print("  tools/mail.py user remove user@domain.com")
 | 
					 | 
				
			||||||
	print("  tools/mail.py user make-admin user@domain.com")
 | 
					 | 
				
			||||||
	print("  tools/mail.py user remove-admin user@domain.com")
 | 
					 | 
				
			||||||
	print("  tools/mail.py user admins (lists admins)")
 | 
					 | 
				
			||||||
	print("  tools/mail.py alias  (lists aliases)")
 | 
					 | 
				
			||||||
	print("  tools/mail.py alias add incoming.name@domain.com sent.to@other.domain.com")
 | 
					 | 
				
			||||||
	print("  tools/mail.py alias add incoming.name@domain.com 'sent.to@other.domain.com, multiple.people@other.domain.com'")
 | 
					 | 
				
			||||||
	print("  tools/mail.py alias remove incoming.name@domain.com")
 | 
					 | 
				
			||||||
	print()
 | 
					 | 
				
			||||||
	print("Removing a mail user does not delete their mail folders on disk. It only prevents IMAP/SMTP login.")
 | 
					 | 
				
			||||||
	print()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
elif sys.argv[1] == "user" and len(sys.argv) == 2:
 | 
					 | 
				
			||||||
	# Dump a list of users, one per line. Mark admins with an asterisk.
 | 
					 | 
				
			||||||
	users = mgmt("/mail/users?format=json", is_json=True)
 | 
					 | 
				
			||||||
	for domain in users:
 | 
					 | 
				
			||||||
		for user in domain["users"]:
 | 
					 | 
				
			||||||
			if user['status'] == 'inactive': continue
 | 
					 | 
				
			||||||
			print(user['email'], end='')
 | 
					 | 
				
			||||||
			if "admin" in user['privileges']:
 | 
					 | 
				
			||||||
				print("*", end='')
 | 
					 | 
				
			||||||
			print()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
elif sys.argv[1] == "user" and sys.argv[2] in ("add", "password"):
 | 
					 | 
				
			||||||
	if len(sys.argv) < 5:
 | 
					 | 
				
			||||||
		if len(sys.argv) < 4:
 | 
					 | 
				
			||||||
			email = input("email: ")
 | 
					 | 
				
			||||||
		else:
 | 
					 | 
				
			||||||
			email = sys.argv[3]
 | 
					 | 
				
			||||||
		pw = read_password()
 | 
					 | 
				
			||||||
	else:
 | 
					 | 
				
			||||||
		email, pw = sys.argv[3:5]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	if sys.argv[2] == "add":
 | 
					 | 
				
			||||||
		print(mgmt("/mail/users/add", { "email": email, "password": pw }))
 | 
					 | 
				
			||||||
	elif sys.argv[2] == "password":
 | 
					 | 
				
			||||||
		print(mgmt("/mail/users/password", { "email": email, "password": pw }))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
elif sys.argv[1] == "user" and sys.argv[2] == "remove" and len(sys.argv) == 4:
 | 
					 | 
				
			||||||
	print(mgmt("/mail/users/remove", { "email": sys.argv[3] }))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
elif sys.argv[1] == "user" and sys.argv[2] in ("make-admin", "remove-admin") and len(sys.argv) == 4:
 | 
					 | 
				
			||||||
	if sys.argv[2] == "make-admin":
 | 
					 | 
				
			||||||
		action = "add"
 | 
					 | 
				
			||||||
	else:
 | 
					 | 
				
			||||||
		action = "remove"
 | 
					 | 
				
			||||||
	print(mgmt("/mail/users/privileges/" + action, { "email": sys.argv[3], "privilege": "admin" }))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
elif sys.argv[1] == "user" and sys.argv[2] == "admins":
 | 
					 | 
				
			||||||
	# Dump a list of admin users.
 | 
					 | 
				
			||||||
	users = mgmt("/mail/users?format=json", is_json=True)
 | 
					 | 
				
			||||||
	for domain in users:
 | 
					 | 
				
			||||||
		for user in domain["users"]:
 | 
					 | 
				
			||||||
			if "admin" in user['privileges']:
 | 
					 | 
				
			||||||
				print(user['email'])
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
elif sys.argv[1] == "alias" and len(sys.argv) == 2:
 | 
					 | 
				
			||||||
	print(mgmt("/mail/aliases"))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
elif sys.argv[1] == "alias" and sys.argv[2] == "add" and len(sys.argv) == 5:
 | 
					 | 
				
			||||||
	print(mgmt("/mail/aliases/add", { "address": sys.argv[3], "forwards_to": sys.argv[4] }))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4:
 | 
					 | 
				
			||||||
	print(mgmt("/mail/aliases/remove", { "address": sys.argv[3] }))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
else:
 | 
					 | 
				
			||||||
	print("Invalid command-line arguments.")
 | 
					 | 
				
			||||||
	sys.exit(1)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user