I am setting up a Flask-RESTful service and have user authentication working. The method that I'm using is:
def generate_auth_token(username, expiration=600):
gen_serial = Serializer(secret_key, expires_in=expiration)
return gen_serial.dumps({'username': username})
I pass the token to the user as follows:
class token(Resource):
decorators = [auth.login_required]
def post(self):
username = g.user
return_token = generate_auth_token(username)
return {'token':return_token.decode()}, 200
And the token is then verified as such so that it does not need to be stored server side:
def verify_auth_token(auth_token):
serial = Serializer(secret_key)
try:
data = serial.loads(auth_token)
except SignatureExpired:
return None
except BadSignature:
return None
serial_user = data['username']
return serial_user
This seems to work well, however I am unsure how to logout the user before the expiration is expired without storing the token serverside. My thought was to pass back a garbage token when the user elects to logout, but I don't think this is an elegant or secure solution.
Any tips would be really helpful!
Rather than a garbage token simply encode no data:
def generate_auth_token(username=None, expiration=600):
gen_serial = Serializer(secret_key, expires_in=expiration)
data = {'username': username} if username is not None else {}
return gen_serial.dumps(data)
Then you can have an invalidate endpoint that requires a login and returns a token without a username:
def invalidate(self):
return_token = generate_auth_token()
return {'token':return_token.decode()}, 200
At that point, you can just handle the possibly missing username field:
def verify_auth_token(auth_token):
serial = Serializer(secret_key)
try:
data = serial.loads(auth_token)
except SignatureExpired:
return None
except BadSignature:
return None
serial_user = data.get('username')
return serial_user
Related
The JSON response of my endpoint returns {} even though I am logging the correct data.
from flask_smorest import Blueprint
bp = Blueprint("auth", __name__, url_prefix="/api/v1/auth/")
#bp.route("/login", methods=["POST"])
#bp.arguments(LoginRequest)
#bp.response(200, JwtTokenResponse)
#bp.response(404, ErrorResponse)
def login(args):
current_app.logger.debug(args)
username = args.get("username", None)
password = args.get("password", None)
current_app.logger.debug(f"Username: {username}")
current_app.logger.debug(f"Password: {password}")
user = User.query.filter_by(username=username).first()
if user is None:
return dict(message="User does not exists"), 404
if not check_password_hash(user.password, password):
return dict(message="Unable to Authenticate user."), 404
access_token = create_access_token(identity=username)
refresh_token = create_refresh_token(identity=username)
response = dict(access_token=access_token, refresh_token=refresh_token)
current_app.logger.debug(f"Response: {response}")
return response, 200
My LoginTokenSchema and ErrorResponse schemas are defined as:
from marshmallow import Schema, fields
class JwtTokenResponse(Schema):
access_token = fields.String()
refresh_token = fields.String()
class ErrorResponse(Schema):
message = fields.String()
When I test the API with a user not in the database or with the wrong password; it will product the correct response with ErrorRespose however with the correct creds it just will output {}, when I check the flask logs I can see access/refresh token dict, what am I doing wrong?
You don't need to call Blueprint.response twice, which is what I did in my question. You can call alt_response with a custom error message or just use abort and let flask-smorest take care of the rest.
#bp.route("/login", methods=["POST"])
#bp.arguments(LoginRequest)
#bp.response(200, SmorestJWTTokenResponse)
def login(args):
current_app.logger.debug(args)
username = args.get("username", None)
password = args.get("password", None)
user = User.query.filter_by(username=username).first()
if user is None:
abort(404, message="User does not exists.")
if not check_password_hash(user.password, password):
abort(404, message="Unable to Authenticate user.")
access_token = create_access_token(identity=username)
refresh_token = create_refresh_token(identity=username)
return dict(access_token=access_token, refresh_token=refresh_token), 200
Once I get to the verify_token function it keeps executing the except statement instead of returning the value in 'id_user' and I'm not sure why. I am using these libraries. flask-login, sqlalchemy, itsdangerous for jsonwebserializer, and wtforms.
Functions
def get_reset_token(user):
serial = Serializer(app.config['SECRET_KEY'], expires_in=900) # 15 mins in seconds
return serial.dumps({'id_user':user.id}).decode('utf-8')
def verify_token(token):
serial = Serializer(app.config['SECRET_KEY'])
try:
user_id = serial.load(token)['id_user']
except:
return None
return Users.query.get('id_user')
def send_mail(user):
token = get_reset_token(user)
message = Message('Password Reset Request', recipients = [user.email], sender='noreply#gmail.com')
message.body= f'''
To Reset your password, click the following link:
{url_for('reset_token', token = token, _external = True)}
If you did not send this email, please ignore this message.
'''
mail.send(message)
ROUTES
#app.route('/password_reset', methods = ['GET', 'POST'])
def password_reset():
form = Password_request()
if request.method == "POST":
if form.validate_on_submit:
user = Users.query.filter_by(email = form.email.data).first()
send_mail(user)
flash('Check your email. Password change request has been sent')
return redirect(url_for('login'))
else:
flash('Your email was not linked to an account')
return render_template('password_reset.html', form = form)
#app.route('/password_reset/<token>', methods = ['GET', 'POST'])
def reset_token(token):
user = verify_token(token)
if user == None:
flash('The token is invalid or expired')
return redirect(url_for('password_reset'))
form = Password_success()
if form.validate_on_submit:
hashed_password=generate_password_hash(form.password.data, method = 'sha256')
user.password = hashed_password
db.session.commit()
flash('Your password has been updated!')
return redirect(url_for('signup'))
def verify_token(token):
serial = Serializer(app.config['SECRET_KEY'])
try:
user_id = serial.load(token)['id_user']
except:
return None
return Users.query.get('id_user') # this looks wrong
Shouldn't the last line of verify_token be return Users.query.get(user_id)? You're assigning the value of the token to that variable , then ignoring it and telling SQLAlchemy to find a record with the ID of the string value 'id_user' which I doubt is what you're intending to do.
def verify_token(token):
serial = Serializer(app.config['SECRET_KEY'])
try:
user_id = serial.load(token)['id_user']
except:
return None
return Users.query.get(user_id) # What happens when you change this?
I'm new with fastapi security and I'm trying to implement the authentication thing and then use scopes.
The problem is that I'm setting an expiration time for the token but after the expiration time the user still authenticated and can access services
import json
from jose import jwt,JWTError
from typing import Optional
from datetime import datetime,timedelta
from fastapi.security import OAuth2PasswordBearer,OAuth2PasswordRequestForm,SecurityScopes
from fastapi import APIRouter, UploadFile, File, Depends, HTTPException,status
from tinydb import TinyDB,where
from tinydb import Query
from passlib.hash import bcrypt
from pydantic import BaseModel
from passlib.context import CryptContext
##
class TokenData(BaseModel):
username: Optional[str] = None
class Token(BaseModel):
access_token: str
token_type: str
router = APIRouter()
SECRET_KEY="e79b2a1eaa2b801bc81c49127ca4607749cc2629f73518194f528fc5c8491713"
ALGORITHM="HS256"
ACCESS_TOKEN_EXPIRE_MINUTES=1
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/dev-service/api/v1/openvpn/token")
db=TinyDB('app/Users.json')
Users = db.table('User')
User = Query
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
class User(BaseModel):
username: str
password:str
def get_user(username: str):#still
user= Users.search((where('name') ==name))
if user:
return user[0]
#router.post('/verif')
async def verify_user(name,password):
user = Users.search((where('name') ==name))
print(user)
if not user:
return False
print(user)
passw=user[0]['password']
if not bcrypt.verify(password,passw):
return False
return user
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=1)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
#router.post("/token", response_model=Token)
async def token_generate(form_data:OAuth2PasswordRequestForm=Depends()):
user=await verify_user(form_data.username,form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(data={"sub": form_data.username}, expires_delta=access_token_expires)
return {"access_token": access_token, "token_type": "bearer"}
#router.get('/user/me')
async def get_current_user(token: str = Depends(oauth2_scheme)):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
token_data = TokenData(username=username)
except JWTError:
raise credentials_exception
user =Users.search(where('name') ==token_data.username)
if user is None:
raise credentials_exception
return user
#router.post('/user')
async def create_user(name,password):
Users.insert({'name':name,'password':bcrypt.hash(password)})
return True
How can I really see the expiration of the token and how can I add the scopes?
I 'd wanted to comment on Unyime Etim's advice
but have no rating yet so this would be a separate answer
I just wanted to add that jwt.decode has a built-in method to check "exp"
and it does check it by default (https://github.com/mpdavis/python-jose/blob/96474ecfb6ad3ce16f41b0814ab5126d58725e2a/jose/jwt.py#L82)
so to make sure your token has been expired you can just handle the corresponding exception ExpiredSignatureError
try:
# decode token and extract username and expires data
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
except ExpiredSignatureError: # <---- this one
raise HTTPException(status_code=403, detail="token has been expired")
except JWTError:
raise credentials_exception
I had pretty much the same confusion when I started out with FastAPI. The access token you created will not expire on its own, so you will need to check if it is expired while validating the token at get_current_user. You could modify your TokenData schema to the code below:
class TokenData(BaseModel):
username: Optional[str] = None
expires: Optional[datetime]
And your get_current_user to:
#router.get('/user/me')
async def get_current_user(token: str = Depends(oauth2_scheme)):
# get the current user from auth token
# define credential exception
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
# decode token and extract username and expires data
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
expires = payload.get("exp")
except JWTError:
raise credentials_exception
# validate username
if username is None:
raise credentials_exception
token_data = TokenData(username=username, expires=expires)
user = Users.search(where('name') == token_data.username)
if user is None:
raise credentials_exception
# check token expiration
if expires is None:
raise credentials_exception
if datetime.utcnow() > token_data.expires:
raise credentials_exception
return user
Scopes is a huge topic on its own, so I can't cover it here. However, you can read more on it at the fastAPI docs here
you don't need to check if it is expired while validating the token at get_current_user(). I was facing same problem. because...
At first I set expiration time==60,
(ACCESS_TOKEN_EXPIRE_MINUTES = 60)
that generated a token, with that token I was testing api.
than I set expiration time==1 and did not generated a token,
(ACCESS_TOKEN_EXPIRE_MINUTES = 1)
expecting that after one minute the previous token will expire but that did not happen because the previous token had 60 minute life.
so, I created new token , tested api with that token. every thing was fine.
after one minute new token got expired.
The answer above does not account that the token_data.expires needs to be converted to a utc date time object.
# check token expiration
if expires is None:
raise credentials_exception
if datetime.utcnow() > datetime.utcfromtimestamp(token_data.expires):
raise credentials_exception
return user
I would modify the example code which doesnt propogate error as JWT does token signature checks and all you need todo is expose the error by propogating the error.
def get_current_user_from_token(
token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)
):
credentials_exception = lambda x : HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=x,
)
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
username: str = payload.get("sub")
print("username/email extracted is ", username)
if username is None:
raise credentials_exception('Could not validate login or error in username/pass')
except JWTError as e:
raise credentials_exception(str(e))
user = get_user(username=username, db=db)
if user is None:
raise credentials_exception('Could not validate login')
return user
I have a profile page for my users where they should be able to update their information. For now they can update their names but I also want phonenumbers, addresses, etc.
The code for updating the name of my user is
class AccountPage(BaseRequestHandler):
def get(self):
self.render('accountpage.html', {'request': self.request, 'user': self.current_user,'loggedin': self.logged_in, 'session': self.auth.get_user_by_session(),})
def post(self):
user = self.current_user
user.name = self.request.POST['name']
user.put()
self.auth.set_session(
self.auth.store.user_to_dict(user))
self.render('accountpage.html', {'request': self.request, 'loggedin': self.logged_in,'user': self.current_user})
But how can I use extra variables such as phonenumbers, address variable etc? The webapp2 User model is an expando model. It did not work to just add the variables to the model:
class User(model.Expando):
"""Stores user authentication credentials or authorization ids."""
#: The model used to ensure uniqueness.
unique_model = Unique
#: The model used to store tokens.
token_model = UserToken
created = model.DateTimeProperty(auto_now_add=True)
updated = model.DateTimeProperty(auto_now=True)
# ID for third party authentication, e.g. 'google:username'. UNIQUE.
auth_ids = model.StringProperty(repeated=True)
# Hashed password. Not required because third party authentication
# doesn't use password.
password = model.StringProperty()
phonenumber = model.StringProperty()
address = model.StringProperty()
I use simpleauth and I get this error msg from simpleauth:
INFO 2015-07-20 06:09:34,426 authhandlers.py:78] user_dict | {'name': u'DAC', 'user_id': 5620703441190912, 'token': u'c9BbE72EmrgTDpG1Dl4tlo', 'token_ts': 1437371676, 'cache_ts': 1437371676, 'remember': 0}
ERROR 2015-07-20 06:09:34,437 authhandlers.py:42] 'phonenumber'
INFO 2015-07-20 06:09:34,445 module.py:812] default: "POST /account/ HTTP/1.1" 404 -
INFO 2015-07-20 06:09:34,501 module.py:812] default: "GET /favicon.ico HTTP/1.1" 200 450
In my BaseRequestHandler I have this cached_property that creates an object.
#webapp2.cached_property
def current_user(self):
"""Returns currently logged in user"""
user_dict = self.auth.get_user_by_session()
logging.info('user_dict | %s ' % user_dict)
if user_dict:
return self.auth.store.user_model.get_by_id(user_dict['user_id'])
else:
return api.users.get_current_user()
Then I tried changing the user model but I still get the ERR phone_number when making these changes.
class BaseRequestHandler(webapp2.RequestHandler):
class User(auth_models.User):
address = ndb.StringProperty(indexed=False)
phone_number = ndb.IntegerProperty(indexed=False)
def dispatch(self):
# Get a session store for this request.
self.session_store = sessions.get_store(request=self.request)
if self.request.host.find('.br') > 0:
i18n.get_i18n().set_locale('pt-br')
elif self.request.host.find('klok') > 0:
i18n.get_i18n().set_locale('sv')
elif self.request.host.find('business') > 0:
i18n.get_i18n().set_locale('en')
else:
lang_code_get = self.request.get('hl', None)
if lang_code_get is None:
lang_code = self.session.get('HTTP_ACCEPT_LANGUAGE', None)
lang_code_browser = os.environ.get('HTTP_ACCEPT_LANGUAGE')
if lang_code:
i18n.get_i18n().set_locale(lang_code)
if lang_code_browser and lang_code is None:
self.session['HTTP_ACCEPT_LANGUAGE'] = lang_code_browser
i18n.get_i18n().set_locale(lang_code_browser)
else:
i18n.get_i18n().set_locale(lang_code_get)
try:
# Dispatch the request.
logging.info('trying to dispatch')
webapp2.RequestHandler.dispatch(self)
except Exception, ex:
logging.error(ex)
self.error(404)
finally:
# Save all sessions.
self.session_store.save_sessions(self.response)
#webapp2.cached_property
def jinja2(self):
"""Returns a Jinja2 renderer cached in the app registry"""
return jinja2.get_jinja2(app=self.app)
#webapp2.cached_property
def session(self):
"""Returns a session using the default cookie key"""
return self.session_store.get_session()
#webapp2.cached_property
def auth(self):
return auth.get_auth()
#webapp2.cached_property
def session_store(self):
return sessions.get_store(request=self.request)
#webapp2.cached_property
def auth_config(self):
"""
..........Dict to hold urls for login/logout
......"""
return {'login_url': self.uri_for('login'),
'logout_url': self.uri_for('logout')}
#webapp2.cached_property
def current_user(self):
"""Returns currently logged in user"""
user_dict = self.auth.get_user_by_session()
logging.info('user_dict | %s ' % user_dict)
if user_dict:
return self.auth.store.user_model.get_by_id(user_dict['user_id'])
else:
return api.users.get_current_user()
As mentioned in the comment above - you should NOT be making any changes in any of the built-in libraries, instead, you can extend them and then add any additional code/properties you need.
So first, you'd need to define your own User model, which would look simmilar to this:
from google.appengine.ext import ndb
import webapp2_extras.appengine.auth.models as auth_models
class User(auth_models.User):
address = ndb.StringProperty(indexed=False)
phone_number = ndb.IntegerProperty(indexed=False)
You are only adding the new properties you need or the ones you need to override, so no created / updated / etc as they're inherited from the model you were referring to.
You then need to work with this model inside your BaseRequestHandler class (I'm not sure what the line self.current_user does, you might need to include the code for that as well).
You can also read this article to get some more ideas: http://gosurob.com/post/20024043690/gaewebapp2accounts
Is there a better pattern for input validation than I'm using in this function?
https://github.com/nathancahill/clearbit-intercom/blob/133e4df0cfd1a146cedb3c749fc1b4fac85a6e1b/server.py#L71
Here's the same function without any validation. It's much more readable, it's short and to the point (9 LoC vs 53 LoC).
def webhook(clearbitkey, appid, intercomkey):
event = request.get_json()
id = event['data']['item']['id']
email = event['data']['item']['email']
person = requests.get(CLEARBIT_USER_ENDPOINT.format(email=email), auth=(clearbitkey, '')).json()
domain = person['employment']['domain']
company = requests.get(CLEARBIT_COMPANY_ENDPOINT.format(domain=domain), auth=(clearbitkey, '')).json()
note = create_note(person, company)
res = requests.post(INTERCOM_ENDPOINT,
json=dict(user=dict(id=id), body=note),
headers=dict(accept='application/json'),
auth=(appid, intercomkey))
return jsonify(note=res.json())
However, it doesn't handle any of these errors:
dict KeyError's (especially nested dicts)
HTTP errors
Invalid JSON
Unexpected responses
Is there a better pattern to follow? I looked into using a data validation library like voluptous but it seems like I'd still have the same problem of verbosity.
Your original code on github seems fine to me. It's a little over complicated, but also handle all cases of error. You can try to improve readability by abstract things.
Just for demonstration, I may write code like this:
class ValidationError(Exception):
"Raises when data validation fails"
pass
class CallExternalApiError(Exception):
"Raises when calling external api fails"
pass
def get_user_from_event(event):
"""Get user profile from event
:param dict event: request.get_json() result
:returns: A dict of user profile
"""
try:
event_type = event['data']['item']['type']
except KeyError:
raise ValidationError('Unexpected JSON format.')
if event_type != 'user':
return ValidationError('Event type is not supported.')
try:
id = event['data']['item']['id']
email = event['data']['item']['email']
except KeyError:
return ValidationError('User object missing fields.')
return {'id': id, 'email': email}
def call_json_api(request_function, api_name, *args, **kwargs):
"""An simple wrapper for sending request
:param request_function: function used for sending request
:param str api_name: name for this api call
"""
try:
res = request_function(*args, **kwargs)
except:
raise CallExternalApiError('API call failed to %s.' % api_name)
try:
return res.json()
except:
raise CallExternalApiError('Invalid response from %s.' % api_name)
#app.route('/<clearbitkey>+<appid>:<intercomkey>', methods=['POST'])
def webhook(clearbitkey, appid, intercomkey):
"""
Webhook endpoint for Intercom.io events. Uses this format for Clearbit and
Intercom.io keys:
/<clearbitkey>+<appid>:<intercomkey>
:clearbitkey: Clearbit API key.
:appid: Intercom.io app id.
:intercomkey: Intercom.io API key.
Supports User events, specifically designed for the User Created event.
Adds a note to the user with their employment and company metrics.
"""
event = request.get_json()
try:
return handle_event(event, clearbitkey, appid, intercomkey)
except (ValidationError, CallExternalApiError) as e:
# TODO: include **res_objs in response
return jsonify(error=str(e))
def handle_event(event):
"""Handle the incoming event
"""
user = get_user_from_event(event)
res_objs = dict(event=event)
person = call_json_api(
requests.get,
'Clearbit',
CLEARBIT_USER_ENDPOINT.format(email=user['email']),
auth=(clearbitkey, '')
)
res_objs['person'] = person
if 'error' in person:
raise CallExternalApiError('Error response from Clearbit.')
domain = person['employment']['domain']
company = None
if domain:
try:
company = call_json_api(
requests.get,
'Clearbit',
CLEARBIT_COMPANY_ENDPOINT.format(domain=domain),
auth=(clearbitkey, ''))
)
if 'error' in company:
company = None
except:
company = None
res_objs['company'] = company
try:
note = create_note(person, company)
except:
return jsonify(error='Failed to generate note for user.', **res_objs)
result = call_json_api(
requests.post,
'Intercom',
(INTERCOM_ENDPOINT, json=dict(user=dict(id=id), body=note),
headers=dict(accept='application/json'),
auth=(appid, intercomkey)
)
return jsonify(note=result, **res_objs)
I hope it helps.