Compare commits

...

49 Commits
0.6 ... 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
nico14571 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
wcolmenares 9e3aad1316 updated readme 2019-03-13 19:56:49 -04:00
wcolmenares 7a581e8357 fix verify someone else token 2019-03-11 23:05:13 -04:00
nico14571 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
29 changed files with 594 additions and 526 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/

261
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,33 +100,30 @@ 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"
}
```
@ -99,118 +150,72 @@ OR return code 403:
{"detail":"You do not have permission to perform this action."}
```
### GET, POST, ... /ungleichotp/
## Authorize the request ##
Standard django rest framework behaviour for updating / listing
objects.
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
- [x] Make seed read only
- [x] Implement registering of new entries
- [x] OTPSerializer: allow to read seed for admin
- [x] Implement deleting entry
- [x] Include verify in ModelSerializer
- [x] 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
- password is used for admin login (?)
- seed
- custom auth method
- [n] try to fake username for django based on name+realm (?)
- [n] maybe overwrite get_username() (?)
- [x] Use Custom authentication - needs to have a user!
- [x] Implement creating new "User"
- by POST / Model based
- [ ] Add tests for verify
- [ ] Add tests for authentication
- [ ] Add proper documentation
- [ ] move totp constants into settings
- [ ] move field lengths into settings
- [ ] make settings adjustable by environment (?)
- [ ] Remove hard coded JSON (?)
- [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
### To document
* Login via username password interactively
* Login via name/realm/token rest
## 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"

View File

@ -1,6 +1,5 @@
from django.contrib import admin
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .models import OTPSeed
@ -8,5 +7,4 @@ from .models import OTPSeed
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
# admin.site.register(OTPSeed, 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

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,46 +0,0 @@
from django.db import models
from django.contrib.auth.models import AbstractUser
# Create your models here.
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 __str__(self):
return "'{}'@{}".format(self.name, self.realm)
# @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
from rest_framework import exceptions
from rest_framework import authentication
from otpauth.models import OTPSeed
from otpauth.serializer import TokenSerializer
class OTPAuthentication(authentication.BaseAuthentication):
def authenticate(self, request):
serializer = TokenSerializer(data=request.data)
if serializer.is_valid():
print("trying to save... {}".format(serializer))
user, token = serializer.save()
else:
raise exceptions.AuthenticationFailed()
return (user, token)

View File

@ -1,103 +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', 'seed')
read_only_fields = ('seed',)
def create(self, validated_data):
validated_data['seed'] = pyotp.random_base32()
return OTPSeed.objects.create(**validated_data)
class VerifySerializerV1(serializers.Serializer):
"""
This is the first version of the serializer that would authenticate the request
itself. This is not necessary anymore starting from version 0.5
Code to be removed prior to 1.0
"""
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 save(self):
token_in = self.validated_data.get('token')
name_in = self.validated_data.get('name')
realm_in = self.validated_data.get('realm')
verifytoken = self.validated_data.get('verifytoken')
verifyname = self.validated_data.get('verifyname')
verifyrealm = self.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()
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
class TokenSerializer(serializers.Serializer):
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):
token_in = self.validated_data.get(self.token_name)
name_in = self.validated_data.get(self.name_name)
realm_in = self.validated_data.get(self.realm_name)
print("auth: {} {} {} ({} {} {} -- {})".format(token_in, name_in, realm_in, self.token_name, self.name_name, self.realm_name, self.validated_data))
# 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()
totp = pyotp.TOTP(db_instance.seed)
if not totp.verify(token_in, valid_window=3):
raise exceptions.AuthenticationFailed()
return (db_instance, token_in)
class VerifySerializer(TokenSerializer):
verifyname = serializers.CharField(max_length=128)
verifytoken = serializers.CharField(max_length=128)
verifyrealm = serializers.CharField(max_length=128)
token_name = 'verifytoken'
name_name = 'verifyname'
realm_name = 'verifyrealm'

View File

@ -1,46 +0,0 @@
from django.shortcuts import render
from rest_framework import viewsets
from rest_framework.parsers import JSONParser
from rest_framework.decorators import action
from rest_framework.response import Response
from django.http import HttpResponse, JsonResponse
from otpauth.serializer import VerifySerializer, OTPSerializer
from otpauth.models import OTPSeed
# Version 2 model - model based ++ verify action
class OTPVerifyViewSet(viewsets.ModelViewSet):
serializer_class = OTPSerializer
queryset = OTPSeed.objects.all()
@action(detail=False, methods=['post'])
def verify(self, request):
serializer = VerifySerializer(data=request.data)
if serializer.is_valid():
print(serializer)
serializer.save()
return Response({'status': 'OK'})
return JsonResponse(serializer.errors, status=400)
# Version 1 model - should be removed
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,35 +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')
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
@ -118,7 +105,6 @@ REST_FRAMEWORK = {
# Internationalization
# https://docs.djangoproject.com/en/2.1/topics/i18n/
@ -137,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()