Compare commits

..

62 commits
0.2 ... master

Author SHA1 Message Date
PCoder
6ba71545a1 Fix psycopg2 bug
Internal Server Error: /admin/login/
Traceback (most recent call last):
  File "/home/app/pyvenv/lib/python3.9/site-packages/django/core/handlers/exception.py", line 34, in inner
    response = get_response(request)
  File "/home/app/pyvenv/lib/python3.9/site-packages/django/core/handlers/base.py", line 115, in _get_response
    response = self.process_exception_by_middleware(e, request)
  File "/home/app/pyvenv/lib/python3.9/site-packages/django/core/handlers/base.py", line 113, in _get_response
    response = wrapped_callback(request, *callback_args, **callback_kwargs)
  File "/home/app/pyvenv/lib/python3.9/site-packages/django/views/decorators/cache.py", line 44, in _wrapped_view_func
    response = view_func(request, *args, **kwargs)
  File "/home/app/pyvenv/lib/python3.9/site-packages/django/contrib/admin/sites.py", line 399, in login
    return LoginView.as_view(**defaults)(request)
  File "/home/app/pyvenv/lib/python3.9/site-packages/django/views/generic/base.py", line 71, in view
    return self.dispatch(request, *args, **kwargs)
  File "/home/app/pyvenv/lib/python3.9/site-packages/django/utils/decorators.py", line 45, in _wrapper
    return bound_method(*args, **kwargs)
  File "/home/app/pyvenv/lib/python3.9/site-packages/django/views/decorators/debug.py", line 76, in sensitive_post_parameters_wrapper
    return view(request, *args, **kwargs)
  File "/home/app/pyvenv/lib/python3.9/site-packages/django/utils/decorators.py", line 45, in _wrapper
    return bound_method(*args, **kwargs)
  File "/home/app/pyvenv/lib/python3.9/site-packages/django/utils/decorators.py", line 142, in _wrapped_view
    response = view_func(request, *args, **kwargs)
  File "/home/app/pyvenv/lib/python3.9/site-packages/django/utils/decorators.py", line 45, in _wrapper
    return bound_method(*args, **kwargs)
  File "/home/app/pyvenv/lib/python3.9/site-packages/django/views/decorators/cache.py", line 44, in _wrapped_view_func
    response = view_func(request, *args, **kwargs)
  File "/home/app/pyvenv/lib/python3.9/site-packages/django/contrib/auth/views.py", line 61, in dispatch
    return super().dispatch(request, *args, **kwargs)
  File "/home/app/pyvenv/lib/python3.9/site-packages/django/views/generic/base.py", line 97, in dispatch
    return handler(request, *args, **kwargs)
  File "/home/app/pyvenv/lib/python3.9/site-packages/django/views/generic/edit.py", line 141, in post
    if form.is_valid():
  File "/home/app/pyvenv/lib/python3.9/site-packages/django/forms/forms.py", line 185, in is_valid
    return self.is_bound and not self.errors
  File "/home/app/pyvenv/lib/python3.9/site-packages/django/forms/forms.py", line 180, in errors
    self.full_clean()
  File "/home/app/pyvenv/lib/python3.9/site-packages/django/forms/forms.py", line 382, in full_clean
    self._clean_form()
  File "/home/app/pyvenv/lib/python3.9/site-packages/django/forms/forms.py", line 409, in _clean_form
    cleaned_data = self.clean()
  File "/home/app/pyvenv/lib/python3.9/site-packages/django/contrib/auth/forms.py", line 205, in clean
    self.user_cache = authenticate(self.request, username=username, password=password)
  File "/home/app/pyvenv/lib/python3.9/site-packages/django/contrib/auth/__init__.py", line 73, in authenticate
    user = backend.authenticate(request, **credentials)
  File "/home/app/pyvenv/lib/python3.9/site-packages/django/contrib/auth/backends.py", line 20, in authenticate
    user = UserModel._default_manager.get_by_natural_key(username)
  File "/home/app/pyvenv/lib/python3.9/site-packages/django/contrib/auth/base_user.py", line 44, in get_by_natural_key
    return self.get(**{self.model.USERNAME_FIELD: username})
  File "/home/app/pyvenv/lib/python3.9/site-packages/django/db/models/manager.py", line 82, in manager_method
    return getattr(self.get_queryset(), name)(*args, **kwargs)
  File "/home/app/pyvenv/lib/python3.9/site-packages/django/db/models/query.py", line 402, in get
    num = len(clone)
  File "/home/app/pyvenv/lib/python3.9/site-packages/django/db/models/query.py", line 256, in __len__
    self._fetch_all()
  File "/home/app/pyvenv/lib/python3.9/site-packages/django/db/models/query.py", line 1242, in _fetch_all
    self._result_cache = list(self._iterable_class(self))
  File "/home/app/pyvenv/lib/python3.9/site-packages/django/db/models/query.py", line 55, in __iter__
    results = compiler.execute_sql(chunked_fetch=self.chunked_fetch, chunk_size=self.chunk_size)
  File "/home/app/pyvenv/lib/python3.9/site-packages/django/db/models/sql/compiler.py", line 1175, in execute_sql
    return list(result)
  File "/home/app/pyvenv/lib/python3.9/site-packages/django/db/models/sql/compiler.py", line 1554, in cursor_iter
    for rows in iter((lambda: cursor.fetchmany(itersize)), sentinel):
  File "/home/app/pyvenv/lib/python3.9/site-packages/django/db/models/sql/compiler.py", line 1554, in <lambda>
    for rows in iter((lambda: cursor.fetchmany(itersize)), sentinel):
  File "/home/app/pyvenv/lib/python3.9/site-packages/django/db/utils.py", line 96, in inner
    return func(*args, **kwargs)
  File "/home/app/pyvenv/lib/python3.9/site-packages/django/db/backends/postgresql/utils.py", line 6, in utc_tzinfo_factory
    raise AssertionError("database connection isn't set to UTC")

See: https://stackoverflow.com/a/68025007
2021-11-03 19:48:37 +05:30
PCoder
ae3c156df7 Update Django from 2.1.4 to 2.2.16 2020-09-02 20:48:34 +05:30
PCoder
1efe6ee078 Add missing parenthesis 2019-09-26 15:45:54 +05:30
PCoder
9703eb6538 Set name, realm and seed for superusers also 2019-09-26 15:26:10 +05:30
PCoder
273a1acf01 Set ALLOWED_HOSTS from .env 2019-09-26 15:16:07 +05:30
PCoder
3f37fe4826 Set username for non-superusers only 2019-09-26 15:10:16 +05:30
79458d54cb Merge branch 'patch-1' into 'master'
Update README.md (Verify using http POST Section)

See merge request ungleich-public/ungleich-otp!3
2019-06-08 12:52:27 +02:00
Ahmed Bilal
0301e1a7e8 Update README.md 2019-06-08 09:36:55 +02:00
Ahmed Bilal
2f2d0c592e Update README.md 2019-06-07 20:06:17 +02:00
9e3aad1316 updated readme 2019-03-13 19:56:49 -04:00
7a581e8357 fix verify someone else token 2019-03-11 23:05:13 -04:00
84afaaa56d Merge branch 'master' into 'master'
Decouple config and add logging

See merge request ungleich-public/ungleich-otp!1
2019-02-11 11:12:53 +01:00
PCoder
d38b5378b0 More logging 2019-02-11 01:06:21 +01:00
PCoder
1b4107306b Add logging 2019-02-10 23:52:52 +01:00
PCoder
d598b9584e Add logs directory 2019-02-10 23:37:15 +01:00
PCoder
636b3d3052 Log errors/debug messages 2019-02-10 23:33:28 +01:00
PCoder
fd0f0b56bd Load configs from .env and add basic logging config 2019-02-10 23:32:59 +01:00
PCoder
e45e5989db Add python-decouple requirement 2019-02-10 23:31:48 +01:00
PCoder
5890d95c59 Update .gitignore 2019-02-10 23:31:26 +01:00
PCoder
27b880ef77 Add values to .env.sample 2019-02-10 23:30:17 +01:00
PCoder
27ba06ce26 Add .env.sample 2019-02-10 23:29:53 +01:00
Nico Schottelius
1a54de525b Cleanup docs, remove debug print 2019-02-08 20:00:28 +01:00
Nico Schottelius
97b612e626 Update doc, run actual authentication on verify 2019-02-08 19:25:07 +01:00
Nico Schottelius
8bd256a1d7 Fix auth!
ungleich-admin can do anything, but verify
ungleich-auth can only verify
rest cannot login
2018-12-31 00:46:29 +01:00
Nico Schottelius
0eb09c31d8 Remove custom list code 2018-12-30 22:52:27 +01:00
Nico Schottelius
1e2d834c59 Adding otpseed as user works 2018-12-30 22:51:34 +01:00
Nico Schottelius
7ab29862f6 Post works, w/o creating an object 2018-12-30 22:41:17 +01:00
Nico Schottelius
1b85b28935 In between hacking commit
Trying to rip out the auth part
2018-12-30 22:30:11 +01:00
Nico Schottelius
2de270859a Fix wrong else 2018-12-30 19:42:55 +01:00
Nico Schottelius
952ff50cbb -- syntax error 2018-12-30 19:42:26 +01:00
Nico Schottelius
11f3c5bcd9 Begin to add static files support 2018-12-30 19:41:13 +01:00
Nico Schottelius
6012eab88d Simplify settings.py 2018-12-30 19:07:41 +01:00
Nico Schottelius
d9ee4ffc80 Move tokenserializer import after otpseed definition 2018-12-30 19:02:34 +01:00
Nico Schottelius
dabe6a08ac reorder 2018-12-30 18:20:24 +01:00
Nico Schottelius
d969399423 Remove self import 2018-12-30 18:16:00 +01:00
Nico Schottelius
fc9e14dd5d Restructure -> easier checkout on app server 2018-12-30 18:10:45 +01:00
Nico Schottelius
6544fccb9a Cleanup and split local/prod 2018-12-30 17:57:02 +01:00
Nico Schottelius
f157cf2539 Move requirements into django folder 2018-12-26 11:29:02 +01:00
Nico Schottelius
6377187004 add hint why stuff works 2018-12-24 20:58:08 +01:00
Nico Schottelius
8636d3f81a Cleanup 2018-12-24 20:46:54 +01:00
Nico Schottelius
45394fa59c Cleanup client 2018-12-24 20:30:12 +01:00
Nico Schottelius
11ab190ebc Update 2018-12-24 20:28:21 +01:00
Nico Schottelius
78de133e16 Remove testfile 2018-12-24 19:22:15 +01:00
Nico Schottelius
2e228f3a0d Phase in ungleichotp 2018-11-18 15:41:47 +01:00
Nico Schottelius
963585806a Update script to use urrlib for server testing 2018-11-18 15:37:28 +01:00
Nico Schottelius
3ada914040 Be python friendly, don't use dashes 2018-11-18 14:33:30 +01:00
Nico Schottelius
d53b980ebf add restapp to client
Signed-off-by: Nico Schottelius <nico@nico-notebook.schottelius.org>
2018-11-18 13:59:15 +01:00
Nico Schottelius
f5f5024981 add client base
Signed-off-by: Nico Schottelius <nico@nico-notebook.schottelius.org>
2018-11-18 13:58:38 +01:00
Nico Schottelius
f59fbf1180 Cleanup dev code 2018-11-18 13:42:16 +01:00
Nico Schottelius
f55498f314 Finish the verifyserializer 2018-11-18 13:35:06 +01:00
Nico Schottelius
0b73e1f5e0 Debug++ 2018-11-18 13:29:07 +01:00
Nico Schottelius
1b42652bd6 Redef the VerifySerializer 2018-11-18 13:25:15 +01:00
Nico Schottelius
d0a3cdce52 ++ doc, begin improving serializers 2018-11-18 13:24:09 +01:00
Nico Schottelius
b16d484406 Require authentication on all rest endpoints 2018-11-18 13:10:51 +01:00
Nico Schottelius
9dc6e02029 Auth: set token as request.auth 2018-11-18 13:05:21 +01:00
Nico Schottelius
26789ff11b Move AUTH code into model of otpauth
To prevent the following exception:

  File "/home/nico/vcs/ungleich-otp/venv/lib/python3.5/site-packages/django/db/models/base.py", line 87, in __new__
    app_config = apps.get_containing_app_config(module)
  File "/home/nico/vcs/ungleich-otp/venv/lib/python3.5/site-packages/django/apps/registry.py", line 249, in get_containing_app_config
    self.check_apps_ready()
  File "/home/nico/vcs/ungleich-otp/venv/lib/python3.5/site-packages/django/apps/registry.py", line 132, in check_apps_ready
    raise AppRegistryNotReady("Apps aren't loaded yet.")
django.core.exceptions.AppRegistryNotReady: Apps aren't loaded yet.
(venv) [12:41] line:ungleichotp%
2018-11-18 12:54:47 +01:00
Nico Schottelius
cbd2446243 Import OTPSeed from the right place 2018-11-18 12:41:28 +01:00
Nico Schottelius
aea92f9d85 Begin to phase in custom authentication 2018-11-18 12:38:50 +01:00
Nico Schottelius
2fb8c91415 Change OTPSeed to AbstractUser 2018-11-17 23:00:36 +01:00
Nico Schottelius
a0d15ecf23 Cleanup! 2018-11-17 22:53:51 +01:00
Nico Schottelius
2d147d961c Implement seed generating 2018-11-17 22:28:17 +01:00
Nico Schottelius
6f7d02f7fc Cleanup, expose seed read only 2018-11-17 22:15:17 +01:00
31 changed files with 682 additions and 587 deletions

4
.env.sample Normal file
View file

@ -0,0 +1,4 @@
SECRET_KEY=ldskjflkdsnejnjsdnf
DEBUG=False
ENABLE_DEBUG_LOG=True
ALLOWED_HOSTS=localhost,.ungleich.ch

4
.gitignore vendored
View file

@ -1,2 +1,6 @@
.idea/
venv/
db.sqlite3
aux/
__pycache__/
static/

382
README.md
View file

@ -1,33 +1,87 @@
# ungleich-otp
# ungleichotp #
ungleich-otp is a full blown authentication and authorisation service
made for micro services.
The basic idea is that every micro service has a (long term) seed and
creates time based tokens (See python pyotp, RFC4226, RFC6238).
The basic idea is that every micro service has a (long term) triple
constisting of (name, realm, seed) and creates time based tokens.
## Setup instructions ##
This basically revamps Kerberos in a simple way into the web area.
ungleichotp has been created and is maintained by [ungleich](https://ungleich.ch/).
Related documentation:
* [Python pyotp](https://pyotp.readthedocs.io/)
* [RFC6238, TOTP](https://tools.ietf.org/html/rfc6238)
* [RFC4120, Kerberos](https://tools.ietf.org/html/rfc4120)
## Overview ##
This repository the reference implementation of the ungleichotp server.
## Using the ungleichotpclient ##
The client can be used to test the ungleich-otp-server.
All client commands need the parameters --auth-name and --auth-realm.
Also either --auth-seed or --auth-token needs to be specified.
```
python manage.py ungleichotpclient create \
--server-url https://otp.ungleich.ch/ungleichotp/
--auth-name admin
--auth-realm ungleich-admin
[--auth-seed THESEEDFORADMIN]
[--auth-token THECURRENTTOKEN]
```
### Creating new users
```
--name USERNAME --realm REALMOFUSER create
```
The seed is randomly created.
### Listing users
```
list
```
### Deleting users
```
--name USERNAME --realm REALMOFUSER delete
```
### Verifying a token is correct
Verify using:
```
--name USERNAME --realm REALMOFUSER --token TOKENTOBEVERIFIED verify
```
You can also verify using a seed:
```
--name USERNAME --realm REALMOFUSER --seed SEEDOFUSER verify
```
## Server Setup instructions ##
This is a standard django project and thus can be easily setup using
```
pip install -r requirements.txt
```
To bootstrap the application, you need your very first trusted seed to
access the application. You can generate it using
```
to be filled in
```
After that, you can run the application using
```
python manage.py createsuperuser
python manage.py runserver
```
The usual instructions on how to setup an https proxy should be followed.
## Realms ##
@ -46,260 +100,122 @@ All micro services that are trusted to authenticate another micro
service should have an entry in the ungleich-auth realm, which allows
them to verify a token of somebody else.
```
| Name | Capabilities |
|------------------+--------------------------------------------|
| ungleich-admin | authenticate, create, delete, list, update |
| ungleich-auth | authenticate |
| all other realms | NO ACCESS |
| ungleich-auth | authenticate, verify |
| all other realms | authenticate |
```
## Usage: REST ##
## Verify using http POST ##
- Use an existing token to connect to the service
- All REST based messages: JSON
### POST: /ungleichotp/verify
Post a JSON object to the server at /ungleichotp/verify/ that
contains the following elements:
Request JSON object:
```
{
version: "1",
name: "your-name",
realm: "your-realm",
token: "current time based token",
verifyname: "name that wants to be authenticated",
verifyrealm: "realm that wants to be authenticated",
verifytoken: "token that wants to be authenticated",
auth_name: "auth-name",
auth_realm: "auth-realm",
auth_token: "current time based token",
name: "name that wants to be authenticated",
realm: "realm that wants to be authenticated",
token: "token that wants to be authenticated"
}
```
Response JSON object:
Either
Either HTTP 200 with
```
{
status: "OK",
}
```
OR
OR return code 403:
* If token for authenticating is wrong, you get
```
{
status: "FAIL",
}
{"detail":"Incorrect authentication credentials."}
```
### POST /register
Register a new seed. Returns an app ID.
Request JSON object:
* If token that is being verified is wrong, you get
```
{
version: "1",
appuuid: "your-app-uuid",
token: "current time based token",
username: "user this app belongs to",
appname: "name of your web app"
}
{"detail":"You do not have permission to perform this action."}
```
Response JSON object:
## Authorize the request ##
```
{
status: "OK",
appuuid: "UUID of your app",
}
```
OR
```
{
status: "FAIL",
error: "Reason for failure"
}
```
### POST /app/register
Register a new app. Returns an app ID.
Request JSON object:
```
{
version: "1",
appuuid: "your-app-uuid",
token: "current time based token",
username: "user this app belongs to",
appname: "name of your web app"
}
```
Response JSON object:
```
{
status: "OK",
appuuid: "UUID of your app",
}
```
OR
```
{
status: "FAIL",
error: "Reason for failure"
}
```
### GET /app
List all registered apps for the current user.
Request JSON object:
```
{
version: "1",
appuuid: "your-app-uuid",
token: "current time based token"
}
```
Response JSON object:
```
{
status: "OK",
apps: [
{
name: "name of your web app"
appuuid: "UUID of your app",
},
{
name: "name of your second web app"
appuuid: "UUID of your second app",
}
]
}
```
### GET /app/UUID
Get seed for APP to be used as a token
Request JSON object:
```
{
version: "1",
appuuid: "your-app-uuid",
token: "current time based token"
}
```
Response JSON object:
```
{
status: "OK",
seed: "seed of your app"
}
```
From the ungleichotp-server, you get a validated information that a
name on a realm authenticated successfully. The associated permissions
("authorization") is application specific and needs to be decided by
your application.
## Usage: OTP
The seeds that you receive can be used for TOTP to authenticate your
apps.
## Database
The database saves a list of appuuids with their seeds and the user
assignments as well as whether the appuuid might use the BUS interface.
Fields:
- appuuid (a random UUID)
- appname (name chosen by the user)
- username (who this appuuid belongs to)
- seed (a random base32 string)
- trusted (boolean, whether app is allowed to use the BUS and the
verify method)
## Environment / Configuration
- POSTGRES_USERNAME
- SECRET_KEY -- random
## Random notes / stuff
django.db.backends.postgresql
django.contrib.admin
```
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'mydatabase',
'USER': 'mydatabaseuser',
'PASSWORD': 'mypassword',
'HOST': '127.0.0.1',
'PORT': '5432',
}
}
```
Custom auth
```
from django.contrib.auth.models import User
from rest_framework import authentication
from rest_framework import exceptions
class ExampleAuthentication(authentication.BaseAuthentication):
def authenticate(self, request):
username = request.META.get('X_USERNAME')
if not username:
return None
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
raise exceptions.AuthenticationFailed('No such user')
return (user, None)
```
Custom user
Dont forget to point AUTH_USER_MODEL to it. Do this before creating any migrations or running manage.py migrate for the first time.
## Limitations ##
* Name, Realm and seed are hard coded to 128 bytes length.
This can be changed, if necessary.
* Only python3 support for ungleichotp
## TODOs
- [x] serialize / input request
- [ ] Remove hard coded JSON
- [ ] Implement registering of new entries
- [ ] Use Custom authentication (?) - needs to have a user
- [ ] Maybe we map name+realm == User (?)
- [x] (server) Serialize / input request
- [x] (server) Make seed read only
- [x] (server) Implement registering of new entries
- [x] (server) OTPSerializer: allow to read seed for admin
- [x] (server) Implement deleting entry
- [x] (server) Include verify in ModelSerializer
- [x] (server) Map name+realm == User (?)
- name == name@realm
- no password
- password is used for admin login (?)
- seed
- custom auth method
- [ ] Implement creating new "User"
- by POST / Model based
- [ ] Implement deleting "User"
- [n] (server) Try to fake username for django based on name+realm (?)
- No need
- [n] (server) maybe overwrite get_username()
- No need
- [x] (server) Use Custom authentication - needs to have a user!
- [x] (server) Implement creating new "User" by POST / Model based
- [n] (server) Remove hard coded JSON in /verify (no - good enough for the moment)
- [x] (server) Fully rename server from ungleichotp to ungleichotpserver
- [x] (security) Ensure that only the right realms can verify
- [x] (security) Ensure that only the right realms can manage
- [ ] (doc) Add proper documentation
- [ ] (server) Add tests for verify
- [ ] (server) Add tests for authentication
- [ ] (server) move totp constants into settings
- [ ] (server) move field lengths into settings
- [ ] (server) Document how admin vs. rest works
- [ ] (server, client) Make settings adjustable by environment - k8s/docker compatible
- [ ] (server, client) Read DB from outside (?) (fallback to sqlite)
- [x] (client) Establish auth using urllib
- [ ] (client) Bootstrap Django + DRF (including an object for CRUD)
- [ ] (client) Add custom authentication / remote auth
- [ ] (client) Show case: any realm vs. specific realm
- [x] (library) Write a "client library" that can use ungleichotp
- [x] (library) extract generic parts from server
- [ ] (library) upload to pypi
## Changelog
### 0.8, 2019-02-08
* Verify needed to call super()
### 0.6, 2018-11-18
* Reuse TokenSerializer for VerifySerializer logic
### 0.5, 2018-11-18
* Require authentication on all rest endpoints by token

2
logs/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*
!.gitignore

View file

@ -3,7 +3,7 @@ import os
import sys
if __name__ == '__main__':
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ungleichotp.settings')
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ungleichotpserver.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:

View file

@ -1,122 +0,0 @@
from nameko.events import EventDispatcher, event_handler
from nameko.rpc import rpc
import json
from nameko.web.handlers import http
from nameko.timer import timer
from nameko.rpc import rpc, RpcProxy
import pyotp
class ServiceA:
""" Event dispatching service. """
name = "service_a"
dispatch = EventDispatcher()
@rpc
def dispatching_method(self, payload):
self.dispatch("event_type", payload)
class ServiceB:
""" Event listening service. """
name = "service_b"
@event_handler("service_a", "event_type")
def handle_event(self, payload):
print("service b received:", payload)
class HttpService:
name = "http_service"
@http('GET', '/get/<int:value>')
def get_method(self, request, value):
return json.dumps({'value': value})
@http('POST', '/post')
def do_post(self, request):
return u"received: {}".format(request.get_data(as_text=True))
@http('GET,PUT,POST,DELETE', '/multi')
def do_multi(self, request):
return request.method
class ServiceTimer:
name ="servicetimer"
dispatch = EventDispatcher()
@timer(interval=3)
def ping(self):
# method executed every second
print("pong")
self.dispatch("ping", "pong")
class LoggerService:
name = "loggerpoint"
@event_handler("servicetimer", "ping")
def handle_event(self, payload):
print("timing receive in logger: ", payload)
class OTPClient:
name = "generic-service-using-otp"
totp = pyotp.TOTP("JBSWY3DPEHPK3PXP")
otp = RpcProxy("otp")
@timer(interval=3)
def auth(self):
token = self.totp.now()
print("Verifying using {}".format(token))
print("Auth1: {}".format(self.otp.verify("app1", token)))
print("Auth-wrongapp: {}".format(self.otp.verify("app2", token)))
print("Auth-noapp: {}".format(self.otp.verify("appNOAPP", token)))
class OTPSeed:
name = "generic-service-using-otp-seed"
otp = RpcProxy("otp")
@timer(interval=10)
def auth(self):
seed = self.otp.get_seed("app1")
totp = pyotp.TOTP(seed)
token = totp.now()
res = self.otp.verify("app1", token)
print("seed / token / res {} {} {}".format(seed, token, res))
class OTPService:
name = "otp"
otp_tokens = {
'app1': 'JBSWY3DPEHPK3PXP',
'app2': 'AIEIU3IAAA'
}
@rpc
def get_seed(self, appid):
if appid in self.otp_tokens:
return self.otp_tokens[appid]
else:
return "NO SEED"
@rpc
def verify(self, appid, token):
if not appid in self.otp_tokens:
return "NO SUCH APP {}".format(appid)
totp = pyotp.TOTP(self.otp_tokens[appid])
if totp.verify(token, valid_window=3):
return "OK"
else:
return "FAIL"

10
otpauth/admin.py Normal file
View file

@ -0,0 +1,10 @@
from django.contrib import admin
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .models import OTPSeed
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
admin.site.register(OTPSeed)

View file

@ -0,0 +1,97 @@
from django.conf import settings
from django.core.management.base import BaseCommand
import pyotp
import json
import urllib.request
import urllib.error
import sys
class Command(BaseCommand):
help = 'Access ungleichotp'
def add_arguments(self, parser):
parser.add_argument('--server-url', required=True)
# For creating / verifying
parser.add_argument('--name', help="Name to create/verify")
parser.add_argument('--realm', help="Realm for create/verify")
parser.add_argument('--token', help="Token for create/verify")
parser.add_argument('--seed', help="Seed for create/verify")
# How to authenticate against ungleich-otp
parser.add_argument('--auth-name', required=True, help="Name for auth")
parser.add_argument('--auth-realm', required=True, help="Realm for auth")
parser.add_argument('--auth-token', help="Token for auth")
parser.add_argument('--auth-seed', help="Seed for auth")
parser.add_argument('command', choices=['create',
'delete',
'list',
'verify'], help='Action to take')
def handle(self, *args, **options):
command_to_verb = { 'create': 'POST',
'delete': 'DELETE',
'list': 'GET' }
method = 'POST'
if not options['auth_token']:
if not options['auth_seed']:
print("Either token or seed are required")
sys.exit(1)
else:
options['auth_token'] = pyotp.TOTP(options['auth_seed']).now()
to_send = {}
# Our credentials
to_send['auth_token'] = options['auth_token']
to_send['auth_name'] = options['auth_name']
to_send['auth_realm'] = options['auth_realm']
if options['command'] in ["list", "get"]:
method = 'GET'
if options['command'] in ["create", "verify"]:
if not options['name'] or not options['realm']:
print("Need to specify --name and --realm")
sys.exit(1)
if options['command'] == "verify" and not options['token']:
if not options['seed']:
print("Need to specify --token or --seed for verify")
sys.exit(1)
else:
options['token'] = pyotp.TOTP(options['seed']).now()
# Client credentials to be verified
to_send['name'] = options['name']
to_send['realm'] = options['realm']
to_send['token'] = options['token']
if options['command'] == "verify":
options['server_url'] = "{}verify/".format(options['server_url'])
self.rest_send(options['server_url'], to_send, method=method)
@staticmethod
def rest_send(serverurl, to_send, method='POST'):
data = json.dumps(to_send).encode("utf-8")
req = urllib.request.Request(url=serverurl,
data=data,
headers={'Content-Type': 'application/json'},
method=method)
f = urllib.request.urlopen(req)
if f.status == 200:
print("Response: {}: {}".format(f.msg, f.read()))
return True
return False

View file

@ -0,0 +1,46 @@
# Generated by Django 2.1.3 on 2018-11-17 22:01
import django.contrib.auth.models
import django.contrib.auth.validators
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0009_alter_user_last_name_max_length'),
]
operations = [
migrations.CreateModel(
name='OTPSeed',
fields=[
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=128)),
('realm', models.CharField(max_length=128)),
('seed', models.CharField(max_length=128)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')),
],
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.AlterUniqueTogether(
name='otpseed',
unique_together={('name', 'realm')},
),
]

View file

57
otpauth/models.py Normal file
View file

@ -0,0 +1,57 @@
from django.db import models
from django.contrib.auth.models import AbstractUser
from rest_framework import exceptions
from rest_framework import authentication
import json
import logging
import pyotp
logger = logging.getLogger(__name__)
class OTPSeed(AbstractUser):
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=128)
realm = models.CharField(max_length=128)
seed = models.CharField(max_length=128)
class Meta:
unique_together = (('name', 'realm'),)
def save(self, *args, **kwargs):
"""
inject username to ensure it stays unique / is setup at all
"""
if not self.is_superuser:
self.username = "{}@{}".format(self.name, self.realm)
else:
self.name = self.username
self.realm = "ungleich-admin"
self.seed = pyotp.random_base32()
super().save(*args, **kwargs)
def __str__(self):
return "'{}'@{} -- {}".format(self.name, self.realm, self.username)
from otpauth.serializer import TokenSerializer
class OTPAuthentication(authentication.BaseAuthentication):
def authenticate(self, request):
logger.debug("in authenticate {}".format(json.dumps(request.data)))
serializer = TokenSerializer(data=request.data)
if serializer.is_valid():
instance, token = serializer.save()
else:
logger.error("serializer is invalid")
raise exceptions.AuthenticationFailed()
# not dealing with admin realm -> can only be auth [see serializer]
if not instance.realm == "ungleich-admin":
if not request.path == "/ungleichotp/verify/":
logger.debug("request.path is not /ungleichotp/verify/")
raise exceptions.AuthenticationFailed()
logger.debug("AUTH DONE: {} - {}".format(request.path, instance))
return (instance, token)

87
otpauth/serializer.py Normal file
View file

@ -0,0 +1,87 @@
import logging
import pyotp
import otpauth
from rest_framework import serializers, exceptions
from otpauth.models import OTPSeed
logger = logging.getLogger(__name__)
# For accessing / modifying the data -- currently unused
class OTPSerializer(serializers.ModelSerializer):
class Meta:
model = OTPSeed
fields = ('name', 'realm', 'seed')
read_only_fields = ('seed',)
def create(self, validated_data):
validated_data['seed'] = pyotp.random_base32()
return OTPSeed.objects.create(**validated_data)
class TokenSerializer(serializers.Serializer):
""" This class is mainly / only used for authentication"""
auth_name = serializers.CharField(max_length=128)
auth_token = serializers.CharField(max_length=128)
auth_realm = serializers.CharField(max_length=128)
token_name = 'auth_token'
name_name = 'auth_name'
realm_name = 'auth_realm'
def save(self):
auth_token = self.validated_data.get(self.token_name)
auth_name = self.validated_data.get(self.name_name)
auth_realm = self.validated_data.get(self.realm_name)
# only 2 special realms can login
# if not auth_realm in ["ungleich-admin", "ungleich-auth" ]:
# logger.error("Auth-realm is neither ungleich-admin "
# "nor ungleich-auth".format()
# )
# raise exceptions.AuthenticationFailed()
logger.debug("auth: [{}]{}@'{}' {} + {})".format(
self.name_name, auth_name, auth_realm,
auth_token, self.validated_data
))
# 1. Verify that the connection might authenticate
try:
logger.debug("Checking in db for name:{} & realm:{}".format(
auth_name, auth_realm
))
db_instance = otpauth.models.OTPSeed.objects.get(name=auth_name, realm=auth_realm)
except (OTPSeed.MultipleObjectsReturned, OTPSeed.DoesNotExist):
logger.error("OTPSeed name: {}, realm: {} does not exist".format(
auth_name, auth_realm
))
raise exceptions.AuthenticationFailed()
logger.debug("Found seed: {}".format(db_instance.seed))
totp = pyotp.TOTP(db_instance.seed)
logger.debug("calculated token = {}".format(totp.now()))
if not totp.verify(auth_token, valid_window=3):
logger.error("totp not verified")
raise exceptions.AuthenticationFailed()
return (db_instance, auth_token)
# For verifying somebody else's token
class VerifySerializer(TokenSerializer):
name = serializers.CharField(max_length=128)
token = serializers.CharField(max_length=128)
realm = serializers.CharField(max_length=128)
token_name = 'token'
name_name = 'name'
realm_name = 'realm'
def save(self):
auth_realm = self.validated_data.get("auth_realm")
if not auth_realm == "ungleich-auth":
logger.error("Auth-realm is not ungleich-auth")
raise exceptions.AuthenticationFailed()
# Do the authentication part
super().save()

34
otpauth/views.py Normal file
View file

@ -0,0 +1,34 @@
from django.shortcuts import render
from rest_framework import viewsets, serializers
from rest_framework.decorators import action
from rest_framework.response import Response
from django.http import JsonResponse
from otpauth.serializer import VerifySerializer, OTPSerializer, TokenSerializer
from otpauth.models import OTPSeed
import json
import logging
logger = logging.getLogger(__name__)
class OTPVerifyViewSet(viewsets.ModelViewSet):
serializer_class = OTPSerializer
queryset = OTPSeed.objects.all()
@action(detail=False, methods=['post'])
def verify(self, request):
"""the standard serializer above already verified that
(name, realm, token) is valid.
Now we inspect the payload and return ok,
if they also verify
"""
logger.debug("in verify {}".format(json.dumps(request.data)))
serializer = VerifySerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response({'status': 'OK'})
return JsonResponse(serializer.errors, status=400)

View file

@ -1,22 +0,0 @@
import json
import pyotp
totp=pyotp.TOTP("PZKBPTHDGSLZBKIZ")
request={}
request['name'] = "info@ungleich.ch"
request['verifyname'] = request['name']
request['token'] = totp.now()
request['verifytoken'] = request['token']
request['realm'] = "ungleich-admin"
request['verifyrealm'] = request['realm']
print(json.dumps(request))
data = json.dumps(request)
with open("outdata", "w") as fd:
fd.write(data)
fd.write("\n")

View file

@ -1,6 +1,10 @@
pyotp>=2.2.6
django>=2.1.2
django==2.2.16
djangorestframework
python-decouple>=3.1
# DB
psycopg2>=2.8,<2.9
# Recommended
markdown

View file

@ -1,9 +0,0 @@
from django.contrib import admin
# Register your models here.
from django.contrib import admin
from .models import OTPSeed
admin.site.register(OTPSeed)

View file

@ -1,27 +0,0 @@
# Generated by Django 2.1.3 on 2018-11-17 09:01
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='OTPSeed',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=128)),
('realm', models.CharField(max_length=128)),
('seed', models.CharField(max_length=128)),
],
),
migrations.AlterUniqueTogether(
name='otpseed',
unique_together={('name', 'realm')},
),
]

View file

@ -1,29 +0,0 @@
from django.db import models
from django.contrib.auth.models import AbstractUser
# Create your models here.
class OTPSeed(models.Model):
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=128)
realm = models.CharField(max_length=128)
seed = models.CharField(max_length=128)
class Meta:
unique_together = (('name', 'realm'),)
def __str__(self):
return "'{}'@{}".format(self.name, self.realm)
# class OTPUser(AbstractUser, OTPSeed):
# @classmethod
# def get_username(cls):
# pass
# @classmethod
# def check_password(cls, raw_password):
# """ receives a time based token"""
# pass
# @classmethod
# def has_usable_password(cls):
# pass

View file

@ -1,57 +0,0 @@
from rest_framework import serializers, exceptions
from otpauth.models import OTPSeed
import pyotp
import otpauth
class OTPSerializer(serializers.ModelSerializer):
class Meta:
model = OTPSeed
fields = ('name', 'realm')
class VerifySerializer(serializers.Serializer):
name = serializers.CharField(max_length=128)
token = serializers.CharField(max_length=128)
realm = serializers.CharField(max_length=128)
verifyname = serializers.CharField(max_length=128)
verifytoken = serializers.CharField(max_length=128)
verifyrealm = serializers.CharField(max_length=128)
def create(self, validated_data):
token_in = validated_data.get('token')
name_in = validated_data.get('name')
realm_in = validated_data.get('realm')
verifytoken = validated_data.get('verifytoken')
verifyname = validated_data.get('verifyname')
verifyrealm = validated_data.get('verifyrealm')
# 1. Verify that the connection might authenticate
try:
db_instance = otpauth.models.OTPSeed.objects.get(name=name_in, realm=realm_in)
except (OTPSeed.MultipleObjectsReturned, OTPSeed.DoesNotExist):
raise exceptions.AuthenticationFailed()
print("serializer found object")
totp = pyotp.TOTP(db_instance.seed)
if not totp.verify(token_in, valid_window=3):
raise exceptions.AuthenticationFailed()
# 2. Verify the requested data
try:
verifyinstance = otpauth.models.OTPSeed.objects.get(name=verifyname, realm=verifyrealm)
except (OTPSeed.MultipleObjectsReturned, OTPSeed.DoesNotExist):
raise exceptions.PermissionDenied()
totp = pyotp.TOTP(verifyinstance.seed)
if not totp.verify(verifytoken, valid_window=3):
raise exceptions.PermissionDenied()
print("All verified!")
return verifyinstance

View file

@ -1,30 +0,0 @@
from django.shortcuts import render
from rest_framework import viewsets
from rest_framework.parsers import JSONParser
from django.http import HttpResponse, JsonResponse
from otpauth.serializer import VerifySerializer, OTPSerializer
from otpauth.models import OTPSeed
class OTPVerifyViewSet(viewsets.ModelViewSet):
serializer_class = OTPSerializer
queryset = OTPSeed.objects.all()
class VerifyViewSet(viewsets.ViewSet):
serializer_class = VerifySerializer
def create(self, request):
data = JSONParser().parse(request)
serializer = VerifySerializer(data=data)
if serializer.is_valid():
print("is valid")
print(serializer)
#serializer.save()
return JsonResponse(serializer.data, status=201)
return JsonResponse(serializer.errors, status=400)
def get_queryset(self):
return []

View file

@ -1,37 +0,0 @@
"""ungleichotp URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/2.1/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path
from django.conf.urls import url, include
from django.contrib.auth.models import User
from rest_framework import routers, serializers, viewsets
from otpauth.models import OTPSeed
from otpauth.views import OTPVerifyViewSet, VerifyViewSet
router = routers.DefaultRouter()
router.register(r'ungleichotp', VerifyViewSet, basename='ungleichotp')
router.register(r'ungleichotpv2', OTPVerifyViewSet, basename='ungleichotpv2')
print(router.urls)
urlpatterns = [
path('admin/', admin.site.urls),
url(r'^', include(router.urls)),
url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework'))
]

81
ungleichotpclient.py Normal file
View file

@ -0,0 +1,81 @@
import pyotp
import json
import urllib.request
import urllib.error
class UngleichOTPClient(object):
token_name = 'token'
name_name = 'name'
realm_name = 'realm'
def __init__(self, name, realm, seed, serverurl):
self.name = name
self.realm = realm
self.seed = seed
self.serverurl = serverurl
def verify(self, name, realm, token):
to_send = {}
# Client credentials to be verified
to_send['verifyname'] = name
to_send['verifyrealm'] = realm
to_send['verifytoken'] = token
# Our credentials
to_send['token'] = pyotp.TOTP(self.seed).now()
to_send['name'] = self.name
to_send['realm'] = self.realm
data = json.dumps(to_send).encode("utf-8")
req = urllib.request.Request(url=self.serverurl,
data=data,
headers={'Content-Type': 'application/json'},
method='POST')
f = urllib.request.urlopen(req)
if f.status == 200:
return True
return False
if __name__ == '__main__':
import argparse
import os
import sys
parser = argparse.ArgumentParser(description='ungleichotp-client')
parser.add_argument('-n', '--name', help="Name (for verification)", required=True)
parser.add_argument('-r', '--realm', help="Realm (for verification)", required=True)
g = parser.add_mutually_exclusive_group(required=True)
g.add_argument('--token', help="Token (for verification)")
g.add_argument('--seed', help="Seed (for verification)")
args = parser.parse_args(sys.argv[1:])
UNGLEICHOTP={}
for env in ['UNGLEICHOTPREALM', 'UNGLEICHOTPNAME', 'UNGLEICHOTPSEED', 'UNGLEICHOTPSERVER' ]:
if not env in os.environ:
raise Exception("Required environment variable missing: {}".format(env))
client = UngleichOTPClient(os.environ['UNGLEICHOTPNAME'],
os.environ['UNGLEICHOTPREALM'],
os.environ['UNGLEICHOTPSEED'],
os.environ['UNGLEICHOTPSERVER'])
if args.seed:
token = pyotp.TOTP(args.seed).now()
else:
token = args.token
try:
if client.verify(args.name, args.realm, token) == True:
print("Verify ok")
except urllib.error.HTTPError as e:
print("Failed to verify: {}".format(e))

View file

View file

@ -10,6 +10,8 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/2.1/ref/settings/
"""
from decouple import config, Csv
import os
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
@ -20,13 +22,9 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# See https://docs.djangoproject.com/en/2.1/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'h^*!&u7yaac_6t02kk4de%$aagp6_j#+_wnw3@rqu6os0tlv#r'
SECRET_KEY = config('SECRET_KEY')
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
# Application definition
@ -51,7 +49,7 @@ MIDDLEWARE = [
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'ungleichotp.urls'
ROOT_URLCONF = 'ungleichotpserver.urls'
TEMPLATES = [
{
@ -69,18 +67,7 @@ TEMPLATES = [
},
]
WSGI_APPLICATION = 'ungleichotp.wsgi.application'
# Database
# https://docs.djangoproject.com/en/2.1/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
}
}
WSGI_APPLICATION = 'ungleichotpserver.wsgi.application'
# Password validation
@ -102,6 +89,22 @@ AUTH_PASSWORD_VALIDATORS = [
]
# Customer user model so that we can use our tokens for authentication!
AUTH_USER_MODEL = 'otpauth.OTPSeed'
# Custom authentication so we can use tokens ourselves
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework.authentication.SessionAuthentication',
'otpauth.models.OTPAuthentication'
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
)
}
# Internationalization
# https://docs.djangoproject.com/en/2.1/topics/i18n/
@ -120,3 +123,72 @@ USE_TZ = True
# https://docs.djangoproject.com/en/2.1/howto/static-files/
STATIC_URL = '/static/'
DEBUG_DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
}
}
DEBUG = config('DEBUG', False, cast=bool)
ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='localhost', cast=Csv())
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql_psycopg2',
'NAME': 'app',
}
}
# Static files
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STATIC_ROOT = os.path.join(BASE_DIR, "static")
STATIC_URL = '/static/'
LOGGING = {
'disable_existing_loggers': False,
'version': 1,
'formatters': {
'standard': {
'format': '%(asctime)s %(levelname)s %(name)s: %(message)s'
}
},
'handlers': {
'default': {
'level': 'DEBUG',
'class': 'logging.handlers.RotatingFileHandler',
'filename': 'logs/debug.log',
'maxBytes': 1024*1024*5,
'backupCount': 10,
'formatter': 'standard',
},
'console': {
'class': 'logging.StreamHandler',
},
},
}
if config('ENABLE_DEBUG_LOG', cast=bool, default=False):
loggers_dict = {}
modules_to_log_list = config(
'MODULES_TO_LOG', default='django', cast=Csv()
)
for custom_module in modules_to_log_list:
logger_item = {
custom_module: {
'handlers': ['default'],
'level': 'DEBUG',
'propagate': True
}
}
loggers_dict.update(logger_item)
LOGGING['loggers'] = loggers_dict
if "DEBUG" in os.environ:
DEBUG = True
ALLOWED_HOSTS = []
DATABASES = DEBUG_DATABASES

14
ungleichotpserver/urls.py Normal file
View file

@ -0,0 +1,14 @@
from django.contrib import admin
from django.urls import path
from django.conf.urls import url, include
from rest_framework import routers
from otpauth.views import OTPVerifyViewSet
router = routers.DefaultRouter()
router.register(r'ungleichotp', OTPVerifyViewSet, basename='ungleichotp')
urlpatterns = [
path('admin/', admin.site.urls),
url(r'^', include(router.urls)),
url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework'))
]

View file

@ -11,6 +11,6 @@ import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ungleichotp.settings')
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ungleichotpserver.settings')
application = get_wsgi_application()