Add first-run admin setup and independent password and API token rotation
This commit is contained in:
@@ -4,7 +4,8 @@ import argparse
|
||||
import os
|
||||
import hashlib
|
||||
import secrets
|
||||
from http.cookies import SimpleCookie
|
||||
from http.cookies import SimpleCookie, CookieError
|
||||
from auth import CredentialStore, verify_password
|
||||
from network.client import NetworkClient
|
||||
from network.config import parse_config, ConfigError
|
||||
import json
|
||||
@@ -91,13 +92,17 @@ class HardwareProvider:
|
||||
|
||||
class Server(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
def __init__(self, port):
|
||||
def __init__(self, port, state_dir=None):
|
||||
super().__init__(('127.0.0.1', port), Handler)
|
||||
self.demo = DemoService()
|
||||
self.hardware = HardwareProvider()
|
||||
self.started = time.time()
|
||||
self.network = NetworkClient()
|
||||
self.auth = json.loads(os.environ['DECK_AUTH_JSON']) if os.environ.get('DECK_AUTH_JSON') else None
|
||||
if os.environ.get('DECK_AUTH_RPC') == '1':
|
||||
from network.rpc import request
|
||||
self.credentials = CredentialStore(rpc=request)
|
||||
else:
|
||||
self.credentials = CredentialStore(Path(state_dir or os.environ.get('DECK_STATE_DIR', ROOT/'.state'))/'auth.json')
|
||||
self.sessions = {}
|
||||
self.login_attempts = []
|
||||
self.auth_lock = threading.Lock()
|
||||
@@ -123,16 +128,44 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return self.headers.get('X-Deck-Ingress', 'management')
|
||||
return 'management'
|
||||
|
||||
def authenticated(self):
|
||||
if self.server.auth is None:
|
||||
return True
|
||||
def api_token_authenticated(self):
|
||||
header = self.headers.get('Authorization', '')
|
||||
if not header.startswith('Bearer ') or len(header)>300:
|
||||
return False
|
||||
record = self.server.credentials.read()
|
||||
if not record or not record['api_token_hash']:
|
||||
return False
|
||||
actual = hashlib.sha256(header[7:].encode()).hexdigest()
|
||||
return secrets.compare_digest(actual,record['api_token_hash'])
|
||||
|
||||
def token_route(self):
|
||||
return (self.command == 'GET' and self.path in ('/api/v1/status','/api/v1/hardware','/api/v1/demo')) or (self.command == 'POST' and self.path in ('/api/v1/demo/start','/api/v1/demo/stop'))
|
||||
|
||||
def session_token(self):
|
||||
try:
|
||||
cookie = SimpleCookie(self.headers.get('Cookie', ''))
|
||||
token = cookie['deck_session'].value
|
||||
except (KeyError, ValueError):
|
||||
return SimpleCookie(self.headers.get('Cookie',''))['deck_session'].value
|
||||
except (KeyError, ValueError, CookieError):
|
||||
return None
|
||||
|
||||
def authenticated(self, session_only=False):
|
||||
if self.headers.get('Authorization'):
|
||||
return not session_only and self.token_route() and self.api_token_authenticated()
|
||||
record = self.server.credentials.read()
|
||||
if not record:
|
||||
return False
|
||||
with self.server.auth_lock:
|
||||
return self.server.sessions.get(token, 0) > time.monotonic()
|
||||
session = self.server.sessions.get(self.session_token())
|
||||
return bool(session and session['expires']>time.monotonic() and secrets.compare_digest(session['revision'],record['password']['hash']))
|
||||
|
||||
def allow_password_attempt(self):
|
||||
with self.server.auth_lock:
|
||||
now = time.monotonic()
|
||||
self.server.login_attempts = [t for t in self.server.login_attempts if now-t<60]
|
||||
if len(self.server.login_attempts)>=10:
|
||||
self.respond({'error':'Zu viele Versuche. Bitte eine Minute warten.'},429)
|
||||
return False
|
||||
self.server.login_attempts.append(now)
|
||||
return True
|
||||
|
||||
def read_json(self):
|
||||
self.connection.settimeout(10)
|
||||
@@ -151,28 +184,21 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return value
|
||||
|
||||
def login(self):
|
||||
if self.server.auth is None:
|
||||
return self.respond({'authenticated': True})
|
||||
if not self.allow_password_attempt():
|
||||
return
|
||||
try:
|
||||
data = self.read_json()
|
||||
password = data.get('password', '')
|
||||
if not isinstance(password, str) or len(password)>256:
|
||||
raise ValueError()
|
||||
with self.server.auth_lock:
|
||||
with self.server.credentials.lock:
|
||||
record = self.server.credentials.read()
|
||||
if not record or not verify_password(data.get('password'),record['password']):
|
||||
return self.respond({'error':'Anmeldung fehlgeschlagen.'},401)
|
||||
token = secrets.token_urlsafe(32)
|
||||
now = time.monotonic()
|
||||
self.server.login_attempts = [t for t in self.server.login_attempts if now-t<60]
|
||||
if len(self.server.login_attempts)>=10:
|
||||
return self.respond({'error':'Zu viele Anmeldeversuche. Bitte eine Minute warten.'}, 429)
|
||||
self.server.login_attempts.append(now)
|
||||
actual = hashlib.pbkdf2_hmac('sha256', password.encode(), bytes.fromhex(self.server.auth['salt']), 600000).hex()
|
||||
if not secrets.compare_digest(actual, self.server.auth['hash']):
|
||||
return self.respond({'error':'Anmeldung fehlgeschlagen.'}, 401)
|
||||
token = secrets.token_urlsafe(32)
|
||||
with self.server.auth_lock:
|
||||
self.server.sessions = {k:v for k,v in self.server.sessions.items() if v>now}
|
||||
if len(self.server.sessions)>=64:
|
||||
self.server.sessions.pop(next(iter(self.server.sessions)))
|
||||
self.server.sessions[token] = now+28800
|
||||
with self.server.auth_lock:
|
||||
self.server.sessions = {k:v for k,v in self.server.sessions.items() if v['expires']>now}
|
||||
if len(self.server.sessions)>=64:
|
||||
self.server.sessions.pop(next(iter(self.server.sessions)))
|
||||
self.server.sessions[token] = dict(expires=now+28800,revision=record['password']['hash'])
|
||||
body = b'{"authenticated":true}'
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type','application/json')
|
||||
@@ -184,6 +210,32 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except (ValueError, OSError):
|
||||
self.respond({'error':'Ungültige Anmeldung.'},400)
|
||||
|
||||
def access_status(self):
|
||||
record = self.server.credentials.read()
|
||||
signed_in = self.authenticated(session_only=True)
|
||||
value = dict(initialized=record is not None, authenticated=signed_in)
|
||||
if signed_in:
|
||||
value.update(api_token_configured=bool(record['api_token_hash']), password_changed_at=record['password_changed_at'], token_changed_at=record['token_changed_at'], account='Administrator', location='Athena' if os.environ.get('DECK_AUTH_RPC') else 'Mac')
|
||||
return self.respond(value)
|
||||
|
||||
def change_credentials(self, kind):
|
||||
if not self.allow_password_attempt():
|
||||
return
|
||||
try:
|
||||
data = self.read_json()
|
||||
key = 'new_password' if kind == 'password' else 'new_token'
|
||||
if set(data) != {'current_password',key}:
|
||||
raise ValueError('Ungültige Zugangsdatenanfrage.')
|
||||
self.server.credentials.change(kind,data['current_password'],data[key])
|
||||
if kind == 'password':
|
||||
with self.server.auth_lock:
|
||||
self.server.sessions.clear()
|
||||
return self.respond({'changed':True,'login_required':kind=='password'})
|
||||
except ValueError as exc:
|
||||
return self.respond({'error':str(exc)},400)
|
||||
except OSError:
|
||||
return self.respond({'error':'Zugangsdaten konnten nicht gespeichert werden.'},503)
|
||||
|
||||
def valid_host(self):
|
||||
if os.environ.get('DECK_PROXY_TOKEN'):
|
||||
return self.ingress() in ('lan','tunnel')
|
||||
@@ -192,18 +244,20 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if not self.valid_host():
|
||||
return self.respond({'error': 'Host rejected'}, 403)
|
||||
if self.path == '/api/v1/auth/status':
|
||||
return self.access_status()
|
||||
if self.path == '/login.js':
|
||||
return self.respond((ROOT/'login.js').read_bytes(), mime='text/javascript')
|
||||
if not self.authenticated() and self.path not in ('/', '/style.css'):
|
||||
return self.respond({'error':'Anmeldung erforderlich.'},401)
|
||||
if not self.authenticated() and self.path == '/':
|
||||
return self.respond((ROOT/'login.html').read_bytes(), mime='text/html; charset=utf-8')
|
||||
routes = {'/': ('index.html', 'text/html; charset=utf-8'), '/app.js': ('app.js', 'text/javascript'), '/style.css': ('style.css', 'text/css'), '/network-ui.js': ('network-ui.js', 'text/javascript')}
|
||||
routes = {'/': ('index.html', 'text/html; charset=utf-8'), '/app.js': ('app.js', 'text/javascript'), '/style.css': ('style.css', 'text/css'), '/network-ui.js': ('network-ui.js', 'text/javascript'), '/access-ui.js': ('access-ui.js', 'text/javascript')}
|
||||
if self.path in routes:
|
||||
name, mime = routes[self.path]
|
||||
return self.respond((ROOT/name).read_bytes(), mime=mime)
|
||||
if self.path == '/api/v1/status':
|
||||
return self.respond(dict(name='Athena Deck', version='0.2.0', state='ready', uptime_seconds=round(time.time()-self.server.started), mode='isolated', location='Athena' if self.server.auth else 'Mac', demo=self.server.demo.status()))
|
||||
return self.respond(dict(name='Athena Deck', version='0.3.0', state='ready', uptime_seconds=round(time.time()-self.server.started), mode='isolated', location='Athena' if os.environ.get('DECK_AUTH_RPC') else 'Mac', demo=self.server.demo.status()))
|
||||
if self.path == '/api/v1/hardware':
|
||||
return self.respond(self.server.hardware.snapshot())
|
||||
if self.path == '/api/v1/network':
|
||||
@@ -214,12 +268,33 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
def do_POST(self):
|
||||
origin = self.headers.get('Origin')
|
||||
if not self.valid_host() or self.headers.get('X-Athena-Deck') != '1' or (origin and origin != 'http://' + self.headers.get('Host', '')):
|
||||
return self.respond({'error': 'Same-origin control required'}, 403)
|
||||
bearer = self.token_route() and self.api_token_authenticated()
|
||||
if not self.valid_host() or (origin and origin != 'http://' + self.headers.get('Host', '')) or (not bearer and self.headers.get('X-Athena-Deck') != '1'):
|
||||
return self.respond({'error':'Same-origin control required'},403)
|
||||
if self.path == '/api/v1/auth/setup':
|
||||
try:
|
||||
data = self.read_json()
|
||||
if set(data) != {'password','api_token'}:
|
||||
raise ValueError('Kennwort und API-Token werden benötigt.')
|
||||
# Container installs always arrive provisioned. Never offer remote claim-on-first-use.
|
||||
if os.environ.get('DECK_AUTH_RPC'):
|
||||
return self.respond({'error':'Serverzugang wird bei der Installation eingerichtet.'},403)
|
||||
self.server.credentials.setup(data['password'],data['api_token'])
|
||||
return self.respond({'initialized':True})
|
||||
except ValueError as exc:
|
||||
return self.respond({'error':str(exc)},400)
|
||||
except OSError:
|
||||
return self.respond({'error':'Einrichtung konnte nicht gespeichert werden.'},503)
|
||||
if self.path == '/api/v1/login':
|
||||
return self.login()
|
||||
if not self.authenticated():
|
||||
return self.respond({'error':'Anmeldung erforderlich.'},401)
|
||||
return self.respond({'error':'Anmeldung oder gültiger API-Token erforderlich.'},401)
|
||||
if self.path in ('/api/v1/auth/password','/api/v1/auth/token'):
|
||||
return self.change_credentials('password' if self.path.endswith('/password') else 'token')
|
||||
if self.path == '/api/v1/logout':
|
||||
with self.server.auth_lock:
|
||||
self.server.sessions.pop(self.session_token(),None)
|
||||
return self.respond({'logged_out':True})
|
||||
if self.path.startswith('/api/v1/network/'):
|
||||
action = self.path.rsplit('/',1)[1]
|
||||
if action not in ('install','import','connect','disconnect','mode','confirm','cancel','delete'):
|
||||
@@ -227,7 +302,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
try:
|
||||
data = self.read_json()
|
||||
if action == 'install':
|
||||
result = self.server.network.install(data.get('password'))
|
||||
result = self.server.network.install(data.get('password'),data.get('api_token'))
|
||||
else:
|
||||
fields = {'import': {'config'}, 'mode': {'mode'}, 'confirm': {'trial_id'}}.get(action,set())
|
||||
if set(data) != fields:
|
||||
|
||||
Reference in New Issue
Block a user