9 Commits
0.3.1 ... 0.5.1

5 changed files with 323 additions and 43 deletions

4
.gitignore vendored
View File

@ -1,2 +1,6 @@
__pycache__/
ENV
config/
*~
.*~

View File

@ -1,6 +1,6 @@
#!/usr/bin/python
import mariadb
import psycopg2
from pbkdf2 import crypt
import argparse
import os
@ -23,30 +23,26 @@ password = args.password
application = args.application
DB_USER = os.environ["DB_USER"]
DB_PASS = os.environ["DB_PASS"]
DB_HOST = os.environ["DB_HOST"]
DB_NAME = os.environ["DB_NAME"]
DB_NAME = "authservice"
pwhash = crypt(password, iterations=100000)
conn = None
cur = None
try:
conn = mariadb.connect(user = DB_USER, password = DB_PASS,
host = DB_HOST, database = DB_NAME)
conn = psycopg2.connect(database = DB_NAME)
conn.autocommit = False
cur = conn.cursor()
cur.execute("""
INSERT INTO users (login, pwhash)
VALUES(?, ?)
INSERT INTO user_t (login, pwhash)
VALUES(%s, %s)
""", [user, pwhash])
cur.execute("""
INSERT INTO user_applications_mapping (application, user)
INSERT INTO user_application_mapping_t (application,"user")
VALUES(
(SELECT id FROM applications WHERE name = ?),
(SELECT id FROM users WHERE login = ?)
(SELECT id FROM application_t WHERE name = %s),
(SELECT id FROM user_t WHERE login = %s)
)
""", [application, user])
conn.commit()

275
auth.py
View File

@ -2,48 +2,80 @@ import time
import connexion
from jose import JWTError, jwt, jwe
import json
from jose.exceptions import ExpiredSignatureError
import werkzeug
import os
import psycopg2
from collections import namedtuple
from pbkdf2 import crypt
from loguru import logger
DB_USER = os.environ["DB_USER"]
DB_PASS = os.environ["DB_PASS"]
DB_HOST = os.environ["DB_HOST"]
DB_NAME = os.environ["DB_NAME"]
JWT_ISSUER = os.environ["JWT_ISSUER"]
import configparser
import random
import string
from flask import request
DB_USER = ""
DB_PASS = ""
DB_HOST = ""
DB_NAME = ""
JWT_ISSUER = ""
try:
DB_USER = os.environ["DB_USER"]
DB_PASS = os.environ["DB_PASS"]
DB_HOST = os.environ["DB_HOST"]
DB_NAME = os.environ["DB_NAME"]
JWT_ISSUER = os.environ["JWT_ISSUER"]
except KeyError:
config = configparser.ConfigParser()
config.read('./config/config.ini')
DB_USER = config["database"]["user"]
DB_PASS = config["database"]["pass"]
DB_HOST = config["database"]["host"]
DB_NAME = config["database"]["name"]
JWT_ISSUER = config["jwt"]["issuer"]
class NoUserException(Exception):
pass
class RefreshTokenExpiredException(Exception):
pass
class NoTokenException(Exception):
pass
class NoValidTokenException(Exception):
pass
class ManyUsersException(Exception):
pass
class ManyTokensException(Exception):
pass
class PasswordMismatchException(Exception):
pass
class RefreshTokenExpiredException(Exception):
pass
UserEntry = namedtuple('UserEntry', ['id', 'login', 'pwhash', 'expiry', 'claims'])
RefreshTokenEntry = namedtuple('RefreshTokenEntry', ['id', 'salt', 'login', 'app', 'expiry'])
JWT_PRIV_KEY = ""
try:
JWT_PRIV_KEY = os.environ["JWT_PRIV_KEY"]
except KeyError:
with open('/opt/app/config/authservice.key', 'r') as f:
with open('./config/authservice.key', 'r') as f:
JWT_PRIV_KEY = f.read()
JWT_PUB_KEY = ""
try:
JWT_PUB_KEY = os.environ["JWT_PUB_KEY"]
except KeyError:
with open('/opt/app/config/authservice.pub', 'r') as f:
with open('./config/authservice.pub', 'r') as f:
JWT_PUB_KEY = f.read()
@ -92,13 +124,57 @@ def getUserEntryFromDB(application: str, login: str):
if conn:
conn.close()
def getRefreshTokenFromDB(application, login, httpClientIp):
conn = None
cur = None
try:
conn = psycopg2.connect(user = DB_USER, password = DB_PASS,
host = DB_HOST, database = DB_NAME)
conn.autocommit = False
with conn:
with conn.cursor() as cur:
cur.execute('SELECT u.id, u.expiry, a.name FROM user_t u, application_t a, user_application_mapping_t m' +
' WHERE u.login = %s AND ' +
' a.name = %s AND ' +
' a.id = m.application AND u.id = m."user"',
(login, application))
userObj = cur.fetchone()
logger.debug("userObj: {}".format(userObj))
if not userObj:
raise NoUserException()
invObj = cur.fetchone()
if invObj:
raise ManyUsersException()
with conn.cursor() as cur:
salt = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(64))
cur.execute('INSERT INTO token_t ("user", salt, expiry, client_ip) VALUES (%s, %s, %s, %s) RETURNING id',
(userObj[0], salt, userObj[1], httpClientIp))
tokenObj = cur.fetchone()
logger.debug("tokenObj: {}".format(tokenObj))
if not tokenObj:
raise NoTokenException()
invObj = cur.fetchone()
if invObj:
raise ManyTokensException()
refreshTokenEntry = RefreshTokenEntry(id=tokenObj[0], salt=salt, login=login, app=userObj[2], expiry=userObj[1])
return refreshTokenEntry
except psycopg2.Error as err:
raise Exception("Error when connecting to database: {}".format(err))
finally:
if conn:
conn.close()
def getUserEntry(application, login, password):
userEntry = getUserEntryFromDB(application, login)
if userEntry.pwhash != crypt(password, userEntry.pwhash):
raise PasswordMismatchException()
return userEntry
def generateToken(**args):
def generateToken(func, **args):
try:
body = args["body"]
@ -120,24 +196,20 @@ def generateToken(**args):
else:
raise KeyError("Neither application, login and password nor encAleTuple given")
logger.debug(f"Tuple: {application} {login} {password}")
if request.headers.getlist("X-Forwarded-For"):
httpClientIp = request.headers.getlist("X-Forwarded-For")[0]
else:
httpClientIp = request.remote_addr
userEntry = getUserEntry(application, login, password)
logger.debug(f"Tuple: {application} {login} {password} {httpClientIp}")
timestamp = int(time.time())
payload = {
"iss": JWT_ISSUER,
"iat": int(timestamp),
"exp": int(timestamp + userEntry.expiry),
"sub": str(userEntry.id),
"aud": application
}
logger.debug("claims: {}".format(userEntry.claims))
for claim in userEntry.claims.items():
logger.debug("add claim {}".format(claim))
payload[claim[0]] = claim[1]
return jwt.encode(payload, JWT_PRIV_KEY, algorithm='RS256')
return func(application, login, password, httpClientIp)
except NoTokenException:
logger.error("no token created")
raise werkzeug.exceptions.Unauthorized()
except ManyTokensException:
logger.error("too many tokens created")
raise werkzeug.exceptions.Unauthorized()
except NoUserException:
logger.error("no user found, login or application wrong")
raise werkzeug.exceptions.Unauthorized()
@ -154,6 +226,55 @@ def generateToken(**args):
logger.error("unspecific exception: {}".format(str(e)))
raise werkzeug.exceptions.Unauthorized()
def _makeSimpleToken(application, login, password, httpClientIp, refresh=False):
userEntry = getUserEntry(application, login, password) if not refresh else getUserEntryFromDB(application, login)
timestamp = int(time.time())
payload = {
"iss": JWT_ISSUER,
"iat": int(timestamp),
"exp": int(timestamp + userEntry.expiry),
"sub": str(userEntry.id),
"aud": application
}
logger.debug("claims: {}".format(userEntry.claims))
for claim in userEntry.claims.items():
logger.debug("add claim {}".format(claim))
payload[claim[0]] = claim[1]
return jwt.encode(payload, JWT_PRIV_KEY, algorithm='RS256')
def _makeRefreshToken(application, login, password, httpClientIp):
refreshTokenEntry = getRefreshTokenFromDB(application, login, httpClientIp)
timestamp = int(time.time())
payload = {
"iss": JWT_ISSUER,
"iat": int(timestamp),
"exp": int(timestamp + refreshTokenEntry.expiry),
"sub": str(refreshTokenEntry.login),
"xap": str(refreshTokenEntry.app),
"xid": str(refreshTokenEntry.id),
"xal": str(refreshTokenEntry.salt)
}
return jwt.encode(payload, JWT_PRIV_KEY, algorithm='RS256')
def _makeRefreshableTokens(application, login, password, httpClientIp):
authToken = _makeSimpleToken(application, login, password, httpClientIp)
refreshToken = _makeRefreshToken(application, login, password, httpClientIp)
return {
"authToken": authToken,
"refreshToken": refreshToken
}
def generateSimpleToken(**args):
return generateToken(_makeSimpleToken, **args)
def generateRefreshableTokens(**args):
return generateToken(_makeRefreshableTokens, **args)
def getPubKey():
return JWT_PUB_KEY
@ -170,3 +291,103 @@ def testToken(user, token_info):
"details": token_info
}
def checkAndInvalidateRefreshToken(login, xid, xal, httpClientIp):
try:
validTokenFound = False
conn = psycopg2.connect(user = DB_USER, password = DB_PASS,
host = DB_HOST, database = DB_NAME)
conn.autocommit = False
with conn:
with conn.cursor() as cur:
cur.execute('SELECT t.id, t.client_ip FROM token_t t, user_t u' +
' WHERE t.valid = true AND ' +
' t.id = %s AND ' +
' t.salt = %s AND ' +
' t."user" = u.id AND ' +
' u.login = %s',
(xid, xal, login))
tokenObj = cur.fetchone()
logger.debug("tokenObj: {}".format(tokenObj))
if not tokenObj:
raise NoTokenException()
invObj = cur.fetchone()
if invObj:
raise ManyTokensException()
if (tokenObj[1] == httpClientIp):
with conn.cursor() as cur:
cur.execute('UPDATE token_t SET used = used + 1 WHERE id = %s',
[ xid ])
validTokenFound = True
else:
logger.warning(f"Client IP in token {tokenObj[1]} and current one {httpClientIp} does not match")
with conn.cursor() as cur:
cur.execute('UPDATE token_t SET valid = false WHERE id = %s',
[ xid ])
if (not validTokenFound):
raise NoValidTokenException()
except psycopg2.Error as err:
raise Exception("Error when connecting to database: {}".format(err))
finally:
if conn:
conn.close()
def refreshTokens(**args):
try:
refreshToken = args["body"]
refreshTokenObj = jwt.decode(refreshToken, JWT_PUB_KEY)
logger.info(str(refreshTokenObj))
if request.headers.getlist("X-Forwarded-For"):
httpClientIp = request.headers.getlist("X-Forwarded-For")[0]
else:
httpClientIp = request.remote_addr
if refreshTokenObj["exp"] < int(time.time()):
raise RefreshTokenExpiredException()
checkAndInvalidateRefreshToken(refreshTokenObj["sub"], refreshTokenObj["xid"], refreshTokenObj["xal"], httpClientIp)
authToken = _makeSimpleToken(refreshTokenObj["xap"], refreshTokenObj["sub"], "", httpClientIp, refresh=True)
refreshToken = _makeRefreshToken(refreshTokenObj["xap"], refreshTokenObj["sub"], "", httpClientIp)
return {
"authToken": authToken,
"refreshToken": refreshToken
}
except JWTError as e:
logger.error("jwt.decode failed: {}".format(e))
raise werkzeug.exceptions.Unauthorized()
except RefreshTokenExpiredException:
logger.error("refresh token expired")
raise werkzeug.exceptions.Unauthorized()
except NoTokenException:
logger.error("no token created/found")
raise werkzeug.exceptions.Unauthorized()
except NoValidTokenException:
logger.error("no valid token found")
raise werkzeug.exceptions.Unauthorized()
except ManyTokensException:
logger.error("too many tokens created/found")
raise werkzeug.exceptions.Unauthorized()
except NoUserException:
logger.error("no user found, login or application wrong")
raise werkzeug.exceptions.Unauthorized()
except ManyUsersException:
logger.error("too many users found")
raise werkzeug.exceptions.Unauthorized()
except PasswordMismatchException:
logger.error("wrong password")
raise werkzeug.exceptions.Unauthorized()
except KeyError:
logger.error("application, login or password missing")
raise werkzeug.exceptions.Unauthorized()
except Exception as e:
logger.error("unspecific exception: {}".format(str(e)))
raise werkzeug.exceptions.Unauthorized()

View File

@ -12,6 +12,14 @@ create table user_t (
expiry integer not null default 600
);
create sequence token_s start with 1 increment by 1;
create table token_t (
id integer primary key not null default nextval('token_s'),
"user" integer not null references user_t (id),
salt varchar(64) not null,
valid boolean not null default true
);
create sequence claim_s start with 1 increment by 1;
create table claim_t (
id integer primary key not null default nextval('claim_s'),

View File

@ -7,8 +7,8 @@ paths:
/token:
post:
tags: [ "JWT" ]
summary: Accept encrypted or clear set of credentials, return JWT token
operationId: auth.generateToken
summary: Accepts encrypted or clear set of credentials, returns JWT token
operationId: auth.generateSimpleToken
requestBody:
content:
'application/json':
@ -23,6 +23,42 @@ paths:
'text/plain':
schema:
type: string
/refreshable:
post:
tags: [ "JWT" ]
summary: Accepts encrypted or clear set of credentials, returns tuple of AuthToken and RefreshToken
operationId: auth.generateRefreshableTokens
requestBody:
content:
'application/json':
schema:
anyOf:
- $ref: '#/components/schemas/User'
- $ref: '#/components/schemas/EncUser'
responses:
'200':
description: Token tuple
content:
'application/json':
schema:
$ref: '#/components/schemas/TokenTuple'
/refresh:
post:
tags: [ "JWT" ]
summary: Accepts refresh token, returns tuple of AuthToken and RefreshToken
operationId: auth.refreshTokens
requestBody:
content:
'text/plain':
schema:
$ref: '#/components/schemas/RefreshToken'
responses:
'200':
description: Token tuple
content:
'application/json':
schema:
$ref: '#/components/schemas/TokenTuple'
/test:
get:
tags: [ "Test" ]
@ -83,3 +119,18 @@ components:
type: string
details:
type: object
AuthToken:
description: Token for authentication purposes, just a string
type: string
RefreshToken:
description: Token for refresh purposes, just a string
type: string
TokenTuple:
description: Test Output
type: object
properties:
authToken:
$ref: '#/components/schemas/AuthToken'
refreshToken:
$ref: '#/components/schemas/RefreshToken'