def refresh_token_grant(old_refresh_token: UUID): """Implements logic for refresh token grant.""" # check token exists in database and get expire time and user id collection = RefreshTokens() old_token_document = collection.find_one( {"token": old_refresh_token}, {"expire_time": 1, "user_id": 1} ) if old_token_document is None: raise InvalidGrant("Refresh token is invalid.") # check token is not expired expire_time = old_token_document["expire_time"] if expire_time < datetime.now(): raise InvalidGrant("Refresh token is expired.") # check user exists user_id = old_token_document["user_id"] user = Users().find_one({"_id": user_id}, {"password_hash": 0}) if user is None: raise InvalidGrant("Refresh token is invalid.") # generate token access_token = LoadedAccessToken( user["_id"], user["username"], user.get("scope", {}) ).encode() refresh_token = OAuth2.generate_refresh_token(user["_id"]) # delete old refresh token from database collection.delete_one({"token": old_refresh_token}) collection.delete_many({"expire_time": {"$lte": datetime.now()}}) return OAuth2.success_response(access_token, refresh_token)
def get(self, token: AccessToken.Payload): request_args = SkipLimitSchema().load(request.args.to_dict()) skip, limit = request_args["skip"], request_args["limit"] # get users from database query = {} count = Users().count_documents(query) cursor = ( Users() .find(query, {"_id": 0, "username": 1, "email": 1, "scope": 1}) .skip(skip) .limit(limit) ) # add role to user while removing scope def _add_role(user): user.update({"role": get_role_for(user.pop("scope", {}))}) return user users = list(map(_add_role, cursor)) return jsonify( {"meta": {"skip": skip, "limit": limit, "count": count}, "items": users} )
def get(self, username: str, token: AccessToken.Payload): # if user in url is not user in token, check user permission if username != token.username: if not token.get_permission("users", "ssh_keys"): raise errors.NotEnoughPrivilege() user = Users().find_one({"username": username}, {"ssh_keys": 1}) if user is None: raise errors.NotFound() ssh_keys = user.get("ssh_keys", []) return jsonify(ssh_keys)
def credentials(): """ Authorize a user with username and password When success, return json object with access and refresh token """ # get username and password from request header if "application/x-www-form-urlencoded" in request.content_type: username = request.form.get("username") password = request.form.get("password") else: username = request.headers.get("username") password = request.headers.get("password") if username is None or password is None: raise BadRequest("missing username or password") # check user exists user = Users().find_one({"username": username}, { "username": 1, "scope": 1, "password_hash": 1 }) if user is None: raise Unauthorized("this user does not exist") # check password is valid password_hash = user.pop("password_hash") is_valid = check_password_hash(password_hash, password) if not is_valid: raise Unauthorized("password does not match") # generate token access_token = AccessToken.encode(user) access_expires = AccessToken.get_expiry(access_token) refresh_token = create_refresh_token(user["username"]) # send response response_json = { "access_token": access_token, "token_type": "bearer", "expires_in": access_expires, "refresh_token": refresh_token, } response = jsonify(response_json) response.headers["Cache-Control"] = "no-store" response.headers["Pragma"] = "no-cache" return response
def get(self, token: AccessToken.Payload, username: str): # if user in url is not user in token, check user permission if username != token.username: if not token.get_permission("users", "read"): raise errors.NotEnoughPrivilege() # find user based on _id or username user = Users().find_one( {"username": username}, {"_id": 0, "username": 1, "email": 1, "scope": 1, "ssh_keys": 1}, ) if user is None: raise errors.NotFound() user["role"] = get_role_for(user.get("scope")) return jsonify(user)
def delete(self, token: AccessToken.Payload, username: str): # delete user deleted_count = Users().delete_one({ "username": username }).deleted_count if deleted_count == 0: raise errors.NotFound() Workers().delete_many({"username": username}) return Response(status=HTTPStatus.NO_CONTENT)
def patch(self, username: str, token: AccessToken.Payload): query = {"username": username} request_json = request.get_json() if username == token.username: # user is trying to set their own password # get current password password_current = request_json.get("current", None) if password_current is None: raise errors.BadRequest() # get current password hash user = Users().find_one(query, {"password_hash": 1}) if user is None: raise errors.NotFound() # check current password is valid is_valid = check_password_hash(user["password_hash"], password_current) if not is_valid: raise errors.Unauthorized() else: # user is trying to set other user's password # check permission if not token.get_permission("users", "change_password"): raise errors.NotEnoughPrivilege() # get new password password_new = request_json.get("new", None) if password_new is None: raise errors.BadRequest() # set new password matched_count = (Users().update_one( query, { "$set": { "password_hash": generate_password_hash(password_new) } }).matched_count) # send response if matched_count == 0: raise errors.NotFound() return Response(status=HTTPStatus.NO_CONTENT)
def get(self, username: str, fingerprint: str): # list of permission to test the matching user against requested_permissions = request.args.getlist("with_permission") or [] query = {} # request using `-` as username searchs on all users if username != "-": query.update({"username": username}) query.update({"ssh_keys.fingerprint": fingerprint}) # database user = Users().find_one( query, { "username": 1, "scope": 1, "ssh_keys": 1, }, ) # no user means no matching SSH key for fingerprint if not user: raise errors.NotFound() key = [ key for key in user["ssh_keys"] if key["fingerprint"] == fingerprint ][-1] for permission in requested_permissions: namespace, perm_name = permission.split(".", 1) if not user.get("scope", {}).get(namespace, {}).get(perm_name): raise errors.NotEnoughPrivilege(permission) payload = { "username": user["username"], "key": key["key"], "type": key["type"], "name": key["name"], } return jsonify(payload)
def password_grant(username: str, password: str): """Implements logic for password grant.""" # check user exists user = Users().find_one({"username": username}) if user is None: raise InvalidGrant("Username or password is invalid.") # check password is valid password_hash = user.pop("password_hash") is_valid = check_password_hash(password_hash, password) if not is_valid: raise InvalidGrant("Username or password is invalid.") # generate token access_token = LoadedAccessToken( user["_id"], user["username"], user.get("scope", {}) ).encode() refresh_token = OAuth2.generate_refresh_token(user["_id"]) return OAuth2.success_response(access_token, refresh_token)
def patch(self, token: AccessToken.Payload, username: str): # find user based on username query = {"username": username} if Users().count_documents(query) != 1: raise errors.NotFound() try: request_json = UserUpdateSchema().load(request.get_json()) except ValidationError as e: raise errors.BadRequest(e.messages) update = {} if "email" in request_json: update["email"] = request_json["email"] if "role" in request_json: update["scope"] = ROLES.get(request_json["role"]) Users().update_one(query, {"$set": update}) return Response(status=HTTPStatus.NO_CONTENT)
def refresh_token(): """ Issue a new set of access and refresh token after validating an old refresh token Old refresh token can only be used once and hence is removed from database Unused but expired refresh token is also deleted from database """ # get old refresh token from request header old_token = request.headers.get("refresh-token") if old_token is None: raise BadRequest("missing refresh-token") # check token exists in database and get expire time and user id try: old_token_document = RefreshTokens().find_one( {"token": UUIDLegacy(UUID(old_token))}, { "expire_time": 1, "username": 1 }) if old_token_document is None: raise Unauthorized("refresh-token invalid") except Exception: raise Unauthorized("refresh-token invalid") # check token is not expired if old_token_document["expire_time"] < getnow(): raise Unauthorized("token expired") # check user exists user = Users().find_one({"username": old_token_document["username"]}, { "username": 1, "scope": 1 }) if user is None: raise Unauthorized("user not found") # generate token access_token = AccessToken.encode(user) refresh_token = create_refresh_token(user["username"]) # delete old refresh token from database RefreshTokens().delete_one({"token": UUID(old_token)}) # send response response_json = { "access_token": access_token, "token_type": "bearer", "expires_in": AccessToken.get_expiry(access_token), "refresh_token": refresh_token, } response = jsonify(response_json) response.headers["Cache-Control"] = "no-store" response.headers["Pragma"] = "no-cache" return response
def delete(self, username: str, fingerprint: str, token: AccessToken.Payload): # if user in url is not user in token, check user permission if username != token.username: if not token.get_permission("users", "ssh_keys"): raise errors.NotEnoughPrivilege() # database result = Users().update_one( {"username": username}, {"$pull": {"ssh_keys": {"fingerprint": fingerprint}}}, ) if result.modified_count > 0: Response(status=HTTPStatus.NO_CONTENT) else: raise errors.NotFound() return Response(status=HTTPStatus.NO_CONTENT)
def ssh_key(): """ Validate ssh public keys exists and matches with username """ # validate request json class KeySchema(Schema): username = fields.String(required=True, validate=validate.Length(min=1)) key = fields.String(required=True, validate=validate.Length(min=1)) try: request_json = KeySchema().load(request.get_json()) except ValidationError as e: raise errors.InvalidRequestJSON(e.messages) # compute fingerprint try: key = request_json["key"] rsa_key = paramiko.RSAKey(data=base64.b64decode(key)) fingerprint = binascii.hexlify(rsa_key.get_fingerprint()).decode() except (binascii.Error, paramiko.SSHException): raise errors.BadRequest("Invalid RSA key") # database username = request_json["username"] user = Users().update_one( { "username": username, "ssh_keys": { "$elemMatch": { "fingerprint": fingerprint } }, }, {"$set": { "ssh_keys.$.last_used": datetime.now() }}, ) if user.matched_count == 0: raise errors.Unauthorized() return Response(status=HTTPStatus.NO_CONTENT)
def post(self, token: AccessToken.Payload): try: request_json = UserCreateSchema().load(request.get_json()) except ValidationError as e: raise errors.InvalidRequestJSON(e.messages) # generate password hash password = request_json.pop("password") request_json["password_hash"] = generate_password_hash(password) # fetch permissions request_json["scope"] = ROLES.get(request_json.pop("role")) try: user_id = Users().insert_one(request_json).inserted_id return jsonify({"_id": user_id}) except DuplicateKeyError: raise errors.BadRequest("User already exists")
def post(self, username: str, token: AccessToken.Payload): # if user in url is not user in token, not allowed to add ssh keys if username != token.username: if not token.get_permission("users", "ssh_keys"): raise errors.NotEnoughPrivilege() try: request_json = KeySchema().load(request.get_json()) except ValidationError as e: raise errors.InvalidRequestJSON(e.messages) # parse public key string key = request_json["key"] key_parts = key.split(" ") if len(key_parts) >= 2: key = key_parts[1] # compute fingerprint try: rsa_key = paramiko.RSAKey(data=base64.b64decode(key)) fingerprint = binascii.hexlify(rsa_key.get_fingerprint()).decode() except (binascii.Error, paramiko.SSHException): raise errors.BadRequest("Invalid RSA key") # get existing ssh key fingerprints query = {"username": username} user = Users().find_one(query, {"ssh_keys.fingerprint": 1}) if user is None: raise errors.NotFound() # find out if new ssh already exist fingerprints = set( [ssh_key["fingerprint"] for ssh_key in user.get("ssh_keys", [])] ) if fingerprint in fingerprints: raise errors.BadRequest("SSH key already exists") # add new ssh key to database ssh_key = { "name": request_json["name"], # ssh-keygen -l -f xxx.pub -E md5 - just data, without dots "fingerprint": fingerprint, "key": key, "type": "RSA", "added": datetime.now(), "last_used": None, } # get PKCS8 - ssh-keygen -e -f xxx.priv -m PKCS8 with tempfile.NamedTemporaryFile(mode="w", suffix=".pub", delete=False) as fp: fp.write("ssh-rsa {} {}\n".format(ssh_key["key"], ssh_key["name"])) keygen = subprocess.run( ["/usr/bin/ssh-keygen", "-e", "-f", fp.name, "-m", "PKCS8"], capture_output=True, text=True, ) if keygen.returncode != 0: raise errors.BadRequest(keygen.stderr) ssh_key.update({"pkcs8_key": keygen.stdout}) os.unlink(fp.name) Users().update_one(query, {"$push": {"ssh_keys": ssh_key}}) return Response(status=HTTPStatus.CREATED)
def asymmetric_key_auth(): """authenticate using signed message and generate tokens - message in X-SSHAuth-Message HTTP header - base64 signature in X-SSHAuth-Signature HTTP header - decode standard message: username:timestamp(UTC ISO) - verify timestamp is less than a minute old - verify username matches our database - verify signature of message with username's public keys - generate tokens""" # check the message's validity try: message = request.headers["X-SSHAuth-Message"] signature = base64.b64decode(request.headers["X-SSHAuth-Signature"]) username, timestamp = message.split(":", 1) timestamp = datetime.datetime.fromisoformat(timestamp) except KeyError as exc: raise errors.BadRequest("Missing header for `{}`".format("".join( exc.args[:1]))) except binascii.Error: raise errors.BadRequest("Invalid signature format (not base64)") except Exception as exc: logger.error(f"Invalid message format: {exc}") logger.exception(exc) raise errors.BadRequest("Invalid message format") if (datetime.datetime.utcnow() - timestamp).total_seconds() > MESSAGE_VALIDITY: raise errors.Unauthorized( f"message too old or peers desyncrhonised: {MESSAGE_VALIDITY}s") user = Users().find_one({"username": username}, { "username": 1, "scope": 1, "ssh_keys": 1 }) if user is None: raise errors.Unauthorized("User not found") # we shall never get there ssh_keys = user.pop("ssh_keys", []) # check that the message was signed with a known private key authenticated = False with tempfile.TemporaryDirectory() as tmp_dirname: tmp_dir = pathlib.Path(tmp_dirname) message_path = tmp_dir.joinpath("message") signatured_path = tmp_dir.joinpath(f"{message_path.name}.sig") with open(message_path, "w", encoding="ASCII") as fp: fp.write(message) with open(signatured_path, "wb") as fp: fp.write(signature) for ssh_key in ssh_keys: pkcs8_data = ssh_key.get("pkcs8_key") if not pkcs8_data: # User record has no PKCS8 version continue pkcs8_key = tmp_dir.joinpath("pubkey") with open(pkcs8_key, "w") as fp: fp.write(pkcs8_data) pkey_util = subprocess.run( [ OPENSSL_BIN, "pkeyutl", "-verify", "-pubin", "-inkey", str(pkcs8_key), "-in", str(message_path), "-sigfile", signatured_path, ], capture_output=True, text=True, ) if pkey_util.returncode == 0: # signature verified authenticated = True break if not authenticated: raise errors.Unauthorized("Could not find matching key for signature") # we're now authenticated ; generate tokens access_token = AccessToken.encode(user) refresh_token = uuid4() # store refresh token in database RefreshTokens().insert_one({ "token": refresh_token, "user_id": user["_id"], "expire_time": getnow() + datetime.timedelta(days=REFRESH_TOKEN_EXPIRY), }) # send response response_json = { "access_token": access_token, "token_type": "bearer", "expires_in": datetime.timedelta(hours=TOKEN_EXPIRY).total_seconds(), "refresh_token": refresh_token, } response = jsonify(response_json) response.headers["Cache-Control"] = "no-store" response.headers["Pragma"] = "no-cache" return response