Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • startuplab/courses/tjts5901-continuous-software-engineering/TJTS5901-K23_template
  • planet-of-the-apes/tjts-5901-apeuction
  • uunot-yliopiston-leivissa/tjts-5901-uunot
  • contain-the-cry/tjts-5901-auction-system
  • avengers/avengers
  • cse6/cse-6
  • 13th/13-sins-of-gitlab
  • fast-and-furious/fast-and-furious
  • back-to-the-future/delorean-auction
  • monty-pythons-the-meaning-of-life/the-meaning-of-life
  • team-atlantis/the-empire
  • code-with-the-wind/auction-project
  • the-pirates/the-pirates
  • do-the-right-thing/do-the-right-thing
  • inception/inception
  • the-social-network-syndicate/the-social-auction-network
  • team-the-hunt-for-red-october/tjts-5901-k-23-red-october
  • good-on-paper/good-paper-project
  • desperados/desperados
19 results
Show changes
Commits on Source (201)
......@@ -69,7 +69,7 @@
},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [ 5001 ],
// "forwardPorts": [ 5001 ],
// Uncomment the next line if you want start specific services in your Docker Compose config.
// "runServices": [],
......@@ -90,5 +90,6 @@
// "terraform": "latest",
"azure-cli": {
"version": "lts"
}
}
}
......@@ -165,3 +165,6 @@ cython_debug/
_docs/
.env
.DS_Store
# Ignore certificates
azure-sp.pem
## Stage names in the pipeline.
stages:
- build
- test
- staging
- smoketest
- deploy
variables:
## Name for the generated image. Change this if you wish, but watch out
## for special characters and spaces!
DOCKER_IMAGE_NAME: ${DOCKER_REGISTRY}/tjts5901
DOCKER_TAG: latest
DOCKER_TAG: ${CI_COMMIT_REF_SLUG}
## (Optional) More verbose output from pipeline. Enabling it might reveal secrets.
#CI_DEBUG_TRACE: "true"
include:
- template: Jobs/SAST.gitlab-ci.yml
## Use buildkit to build the container. `docker build` requires elevated
## privileges, and can be security issue: https://docs.gitlab.com/runner/executors/kubernetes.html#using-dockerdind
## Use buildkit to build the container.
## Buildkit: https://github.com/moby/buildkit
## Example gitlab-ci buildkit template: https://gitlab.com/txlab/ci-templates
build:
stage: build
image:
name: moby/buildkit:rootless
name: moby/buildkit:v0.10.6-rootless
entrypoint: [ "sh", "-c" ]
variables:
BUILDKITD_FLAGS: --oci-worker-no-process-sandbox
......@@ -30,10 +34,10 @@ build:
- test -z "${DOCKER_REGISTRY}" && (echo "Missing required variable DOCKER_REGISTRY. See 'Pipeline setup.md'"; exit 1)
- test -z "${DOCKER_AUTH_CONFIG}" && (echo "Missing required variable DOCKER_AUTH_CONFIG. See 'Pipeline setup.md'"; exit 1)
- test -z "${DOCKER_IMAGE_NAME}" && (echo "Missing image name variable."; exit 1)
## Save docker login credentials from gitlab into a place where buildkit is looking for them.
- mkdir -p ${HOME}/.docker && echo "${DOCKER_AUTH_CONFIG}" > "${HOME}/.docker/config.json"
## Simple check that the registry exists in login information
- grep "\\b${DOCKER_REGISTRY}\\b" "${DOCKER_AUTH_CONFIG}" || (echo "Could not find docker registry in docker login information. Check DOCKER_AUTH_CONFIG"; exit 1)
## Copy docker login credentials from gitlab into a place where buildkit is looking for them.
- mkdir -p ${HOME}/.docker && cp "${DOCKER_AUTH_CONFIG}" "${HOME}/.docker/config.json"
- grep "\\b${DOCKER_REGISTRY}\\b" "${HOME}/.docker/config.json" || (echo "Could not find docker registry in docker login information. Check DOCKER_AUTH_CONFIG"; exit 1)
script:
# Build the image, and push it to registry.
......@@ -44,3 +48,157 @@ build:
--local dockerfile=. \
--opt build-arg:CI_COMMIT_SHA=${CI_COMMIT_SHA} \
--output type=image,name=${DOCKER_IMAGE_NAME}:${DOCKER_TAG},push=true
sast:
## Static Application Security Test
## You can override the included template(s) by including variable overrides
## SAST customization:
## https://docs.gitlab.com/ee/user/application_security/sast/#customizing-the-sast-settings
stage: test
## Run the tests. If any of the tests fails, pipeline is rejected.
test:
## Optional: include stage and environment name
stage: test
image: ${DOCKER_IMAGE_NAME}:${DOCKER_TAG}
variables:
## Setup variable pointin to mongo service
## Notice: the `mongo` address might not work.
MONGO_URL: mongodb://mongo/tjts5901-test
## When job is started, also start these things
services:
- name: mongo:4.2 # update to reflect same version used on production
alias: mongo
script:
- pip install --disable-pip-version-check -e /app[test]
## Run tests with coverage reporting
- coverage run -m pytest
## Run basic reporting for badge
- coverage report
## Generate detailed report for gitlab annotations.
- coverage xml -o coverage.xml
coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
## Job to setup new staging job.
deploy to staging:
stage: staging
## Only run this stage when main branch receives changes.
only:
- main
## Use microsoft provided azure cli image, that contains az cli.
image: mcr.microsoft.com/azure-cli
## Setup the environment variables. The can be accessed through the gitlab
## Deployments -> Environments. Generates url based on the app and branch name.
environment:
name: $CI_JOB_STAGE
url: https://${AZURE_APP_NAME}-${CI_ENVIRONMENT_SLUG}.azurewebsites.net
before_script:
## Make sanity check that gitlab variables stage is done.
- test -z "${AZURE_SP_NAME}" && (echo "Missing required variable AZURE_SP_NAME. See 'Staging.md'"; exit 1)
- test -f "${AZURE_SP_CERT}" || ( echo "AZURE_SP_CERT (${AZURE_SP_CERT}) file is missing!"; exit 1)
- test -z "${AZURE_APP_NAME}" && (echo "Missing required variable AZURE_APP_NAME. See 'Staging.md'"; exit 1)
- test -z "${AZURE_RESOURCE_GROUP}" && (echo "Missing required variable DOCKER_AUTH_CONFIG. See 'Staging.md'"; exit 1)
## Login into azure
- az login --service-principal -u "${AZURE_SP_NAME}" -p "${AZURE_SP_CERT}" --tenant "jyu.onmicrosoft.com"
script:
## Create staging slot and copy settings from production
- |
az webapp deployment slot create -n "$AZURE_APP_NAME" -g "$AZURE_RESOURCE_GROUP" \
--slot "$CI_ENVIRONMENT_SLUG" --configuration-source "$AZURE_APP_NAME"
## TODO: Create a snapshot of database, and use it.
## If you need to change settings see: https://docs.microsoft.com/en-us/cli/azure/webapp/config/appsettings
## Change container tag to reflect branch we're running on
- |
az webapp config container set -n "$AZURE_APP_NAME" -g "$AZURE_RESOURCE_GROUP" \
--docker-custom-image-name "${DOCKER_IMAGE_NAME}:${DOCKER_TAG}" -s "$CI_ENVIRONMENT_SLUG"
## In case slot already existed, restart the slot
- az webapp restart -n "$AZURE_APP_NAME" -g "$AZURE_RESOURCE_GROUP" -s "$CI_ENVIRONMENT_SLUG"
## Restart is not immediate, it takes a sec or two, depending on container changes.
- sleep 20
## Store server info as artifact for prosperity
- curl "$CI_ENVIRONMENT_URL/server-info" -o server-info.json
artifacts:
paths:
- server-info.json
## Run smoketest to check that staging app is responding as expected.
staging smoketest:
stage: smoketest
image: ${DOCKER_IMAGE_NAME}:${DOCKER_TAG}
environment:
name: staging
url: https://${AZURE_APP_NAME}-${CI_ENVIRONMENT_SLUG}.azurewebsites.net
only:
- main
script:
- pip install --disable-pip-version-check -e .[test]
## Environment url can be inherited from CI_ENVIROMNENT_URL, which is one defined
## in the `environment.url:`. Using it here explicitly.
- echo "Testing againsta deployment address ${CI_ENVIROMNENT_URL}"
- pytest -v --environment-url="${CI_ENVIROMNENT_URL}" tests/test_smoke.py
## Push latest image into registry with the `latest` tag.
docker tag latest:
stage: deploy
environment:
name: production
image: docker:20.10.23
only:
- main
script:
## Copy credentials to container
- mkdir -p ${HOME}/.docker && echo "${DOCKER_AUTH_CONFIG}" > "${HOME}/.docker/config.json"
## Add the `latest` tag to the image we have build.
- docker buildx imagetools create ${DOCKER_IMAGE_NAME}:${DOCKER_TAG} --tag ${DOCKER_IMAGE_NAME}:latest
## Swap the production and staging slots around.
staging to production:
stage: deploy
## Only run this stage when main branch receives changes.
only:
- main
## Use microsoft provided azure cli image, that contains az cli.
image: mcr.microsoft.com/azure-cli
environment:
name: production
url: https://${AZURE_APP_NAME}.azurewebsites.net/
before_script:
## Login into azure
- az login --service-principal -u "${AZURE_SP_NAME}" -p "${AZURE_SP_CERT}" --tenant "jyu.onmicrosoft.com"
script:
## Swap production and staging slots.
- az webapp deployment slot swap -g "$AZURE_RESOURCE_GROUP" -n "$AZURE_APP_NAME" -s staging --target-slot production
## Summary
Briefly describe the issue and its impact on the project concisely.
## Steps to Reproduce
1. List the steps to reproduce the issue
2. Provide any relevant details such as browser, device, version, etc.
## Expected Outcome
What should happen after following the steps?
## Actual Outcome
What actually happens after following the steps?
## Additional Context
- Include any relevant screenshots, logs, or code snippets
- Indicate if the issue occurs only in certain conditions, environments, or browsers
## Possible fixes
If you are familiar with the issue and in a position to help, it would be appreciated!
/label ~bug
### As a ...
(user role)
### I want to ...
(goal)
### So that ...
(benefit)
### I know I'm done when ...
(the thing does thang)
/label ~"User Story"
[submodule "docs/tjts5901"]
path = docs/tjts5901
url = git@gitlab.jyu.fi:startuplab/courses/tjts5901-continuous-software-engineering/docs.git
url = https://gitlab.jyu.fi/startuplab/courses/tjts5901-continuous-software-engineering/docs.git
{
"terminal.integrated.shellIntegration.enabled": true,
"grammarly.files.include": [
"**/readme.md",
"**/README.md",
"**/*.txt",
"*.md"
],
"grammarly.selectors": [
{
"language": "markdown",
"scheme": "file"
}
]
}
......@@ -21,7 +21,7 @@ WORKDIR /app
## Declare default flask app as environment variable
## https://flask.palletsprojects.com/en/2.2.x/cli/
ARG FLASK_APP=tjts5901.app
ARG FLASK_APP=tjts5901.app:flask_app
ENV FLASK_APP=${FLASK_APP}
## Setup the default port for flask to listen on
......@@ -31,13 +31,13 @@ ENV FLASK_RUN_PORT=${FLASK_RUN_PORT}
## Run Flask app when container started, and listen all the interfaces
## Note: CMD doesn't run command in build, but defines an starting command
## when container is started (or arguments for ENTRYPOINT).
CMD flask run --host=0.0.0.0 # --port=${FLASK_RUN_PORT} --app=${FLASK_APP}}
#CMD flask run --host=0.0.0.0 # --port=${FLASK_RUN_PORT} --app=${FLASK_APP}
CMD gunicorn --bind "0.0.0.0:${FLASK_RUN_PORT}" "${FLASK_APP}"
## Examples for other commands:
## Run nothing, so that the container can be used as a base image
#CMD ["bash", "-c", "sleep infinity"]
## Run Flask app using Gunicorn, which unlike Flask, doesn't complain about being development thing.
#CMD gunicorn --bind "0.0.0.0:${PORT}"" tjts5901.app:app
## Install requirements using pip. This is done before copying the app, so that
## requirements layer is cached. This way, if app code changes, only app code is
......@@ -49,6 +49,10 @@ RUN pip --disable-pip-version-check install -r /tmp/pip-tmp/requirements.txt &&
## Copy app to WORKDIR folder
COPY . .
## Compile translations before installing app. This is done to make sure that
## translations are compiled before app is installed.
RUN pybabel compile -f -d src/tjts5901/translations/
## Install self as editable (`-e`) module. In a long run it would be recommeded
## to remove `COPY` and only install app as a package.
RUN pip --disable-pip-version-check install -v -e .
......@@ -58,6 +62,9 @@ RUN pip --disable-pip-version-check install -v -e .
ARG CI_COMMIT_SHA
ENV CI_COMMIT_SHA=${CI_COMMIT_SHA}
## Download the currency exchange rates from European Central Bank
RUN flask update-currency-rates
## Save build date and time
RUN echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> /app/.env
......
# TJTS5901 Course Template Project
This is the template for 2023 TJTS5901 Continuous Software Engineering -course.
Project template for 2023 TJTS5901 Continuous Software Engineering -course.
Sisu: <https://sisu.jyu.fi/student/courseunit/otm-38b7f26b-1cf9-4d2d-a29b-e1dcb5c87f00>
Moodle: <https://moodle.jyu.fi/course/view.php?id=20888>
- Sisu: <https://sisu.jyu.fi/student/courseunit/otm-38b7f26b-1cf9-4d2d-a29b-e1dcb5c87f00>
- Moodle: <https://moodle.jyu.fi/course/view.php?id=20888>
To get started with the project, see [`week_1.md`](./week_1.md)
The application is deployed at <https://tjts5901-app.azurewebsites.net>
## Start the app
Repository provides an `docker-compose` file to start the app:
Repository provides an `docker-compose` file to start the app. Edit `docker-compose.yml` to uncomment the ports, and run:
```sh
docker-compose up --build tjts5901
......@@ -21,3 +25,8 @@ docker run -it -p 5001:5001 -e "FLASK_DEBUG=1" -v "${PWD}:/app" tjts5901
```
Please see the `docs/tjts5901` folder for more complete documentation.
## Reporting issues and bugs
To report bugs, please use [the project "issues" form](https://gitlab.jyu.fi/startuplab/courses/tjts5901-continuous-software-engineering/TJTS5901-K23_template/-/issues/new?issuable_template=Default)
[python: **.py]
[jinja2: **/templates/**.html]
......@@ -34,10 +34,16 @@ services:
## Don't restart container if it exits. Useful for debugging, not for production.
restart: 'no'
# Example for another service, such as a database.
# mongodb:
# image: mongo:stable
# restart: unless-stopped
## Start a mongodb container and link it to the app container
depends_on:
- mongodb
# MongoDB container
mongodb:
image: mongo:4.2
restart: unless-stopped
ports:
- 27017:27017
volumes:
tjts5901-vscode-extensions:
"""
Hack to get the hosted gitlab magiclink extension to work with mkdocs.
See: https://github.com/facelessuser/pymdown-extensions/issues/933
"""
import pymdownx.magiclink
base_url = "https://gitlab.jyu.fi"
pymdownx.magiclink.PROVIDER_INFO["gitlab"].update({
"url": base_url,
"issue": "%s/{}/{}/issues/{}" % base_url,
"pull": "%s/{}/{}/merge_requests/{}" % base_url,
"commit": "%s/{}/{}/commit/{}" % base_url,
"compare": "%s/{}/{}/compare/{}...{}" % base_url,
})
def define_env(env):
pass
......@@ -7,6 +7,7 @@
site_name: JYU TJTS5901 Course Documentation
site_author: University of Jyväskylä / StartupLab
copyright: © Copyright 2023 - <a href="https://www.jyu.fi">University of Jyväskylä</a> - This work is licensed under a <a rel="license" href="https://creativecommons.org/licenses/by-sa/4.0/">Creative Commons Attribution-ShareAlike 4.0 International</a>.
theme:
name: material
......@@ -22,7 +23,7 @@ theme:
logo: https://gitlab.jyu.fi/uploads/-/system/appearance/header_logo/1/jyu-logo3.png
icon:
repo: fontawesome/brands/gitlab
repo: material/file-document
extra_css:
- jyu-stylesheet.css
......@@ -50,12 +51,26 @@ markdown_extensions:
- pymdownx.emoji:
emoji_index: !!python/name:materialx.emoji.twemoji
emoji_generator: !!python/name:materialx.emoji.to_svg
- pymdownx.magiclink:
user: startuplab/courses/tjts5901-continuous-software-engineering
repo: TJTS5901-K23_template
provider: gitlab
repo_url_shortener: true
repo_url_shorthand: true
plugins:
- offline
- search
- git-revision-date-localized:
type: date
fallback_to_build_date: true
- macros:
module_name: macros
# Change to something else
j2_variable_start_string: "{j2{"
j2_variable_end_string: "}j2}"
extra:
social:
......@@ -69,4 +84,6 @@ extra:
link: https://sisu.jyu.fi/student/courseunit/otm-38b7f26b-1cf9-4d2d-a29b-e1dcb5c87f00
text: Sisu
- icon: fontawesome/brands/discord
link: http://discord.com
link: https://discord.gg/QfbAjzxJYd
- icon: simple/zoom
link: https://jyufi.zoom.us/j/64685455360
# Weekly assignment - documentation / reporting requirements
All the code in JYU Gitlab should be well documented.
i.e. by reading your code, the reader understands what you have done based on the documentation without the need for further explanation.
In addition, create a weekly `.md` text file in `docs/` -folder, in which everyone on team will report:
- The tasks that they have performed. Everyone on the team should do it themselves.
- How have the team addressed top 10 web application security risks (OWASP)? Choose at least 5 most applicable.
You can use markdown syntax and link appropriate commits, merges, issues and such – or not. To learn more about GitLab flavored markdown, see: <https://docs.gitlab.com/ee/user/markdown.html>
......@@ -20,3 +20,15 @@ FLASK_DEBUG=1
# Enable rich logging for more human readable log output. Requires installing
# `rich` and `flask-rich` packages.
#RICH_LOGGING=1
# Mongodb connection string
MONGO_URL=mongodb://mongodb:27017/tjts5901
# Sentry logging. Fetch the DSN ingest key: <https://docs.sentry.io/platforms/python/guides/flask/>
#SENTRY_DSN=https://ABC@XYZ.ingest.sentry.io/123
# Setup sentry environment to separate development issues from production. This variable name is
# maybe set by gitlab pipeline. <https://docs.gitlab.com/ee/ci/environments/>
CI_ENVIRONMENT_NAME=development
# Setup CI environment url to point on localhost for testing purposes.
CI_ENVIRONMENT_URL=http://localhost:5001
......@@ -28,11 +28,16 @@ dependencies = {file = ["requirements.txt"]}
[project.optional-dependencies]
test = [
"pytest",
"coverage",
"coverage[toml]",
"requests",
"faker",
]
docs = [
"mkdocs",
"mkdocs-material",
"mkdocs-git-revision-date-localized-plugin",
"mkdocs-macros-plugin",
]
[build-system]
......@@ -43,3 +48,15 @@ build-backend = "setuptools.build_meta"
testpaths = [
"tests",
]
filterwarnings = [
## Silence json encoder warnings for Flask >=2.3
# "ignore::DeprecationWarning:mongoengine.connection",
# "ignore::DeprecationWarning:flask_mongoengine.json",
# "ignore::DeprecationWarning:flask.json.provider",
]
[tool.coverage.run]
branch = true
source_pkgs = [
"tjts5901",
]
......@@ -2,11 +2,23 @@
importlib-metadata
# Framework and libraries
flask
flask==2.2.2
python-dotenv
flask-login
flask-babel
flask-mongoengine==1.0
CurrencyConverter
Flask-APScheduler
# Git hooks
pre-commit
# Sentry for error reporting
sentry-sdk[flask]
sentry-sdk[pymongo]
# More production-ready web server
#gunicorn
gunicorn
......@@ -6,7 +6,14 @@ JYU TJTS5901 Course project
from importlib_metadata import (PackageNotFoundError,
version)
from .app import create_app
try:
__version__ = version(__name__)
except PackageNotFoundError:
__version__ = "unknown"
__all__ = [
"create_app",
"__version__",
]
......@@ -7,8 +7,10 @@ Flask tutorial: https://flask.palletsprojects.com/en/2.2.x/tutorial/
"""
import logging
from os import environ
from typing import Dict, Optional
import os
from typing import Dict, Literal, Optional
from dotenv import load_dotenv
from flask import (
......@@ -18,7 +20,13 @@ from flask import (
request,
)
from flask_babel import _
from .utils import get_version
from .db import init_db
from .i18n import init_babel
logger = logging.getLogger(__name__)
def create_app(config: Optional[Dict] = None) -> Flask:
......@@ -29,15 +37,59 @@ def create_app(config: Optional[Dict] = None) -> Flask:
"""
flask_app = Flask(__name__, instance_relative_config=True)
if config:
flask_app.config.from_mapping(
SECRET_KEY='dev',
BRAND=_("Hill Valley DMC dealership"),
)
# load the instance config, if it exists, when not testing
if config is None:
flask_app.config.from_pyfile('config.py', silent=True)
else:
flask_app.config.from_mapping(config)
# Set flask config variable for "rich" loggin from environment variable.
flask_app.config.from_envvar("RICH_LOGGING", silent=True)
# Initialize logging early, so that we can log the rest of the initialization.
from .logging import init_logging # pylint: disable=import-outside-toplevel
init_logging(flask_app)
# ensure the instance folder exists
try:
os.makedirs(flask_app.instance_path)
except OSError:
pass
# Initialize the Flask-Babel extension.
init_babel(flask_app)
# Initialize the database connection.
init_db(flask_app)
# Initialize the scheduler.
from .scheduler import init_scheduler # pylint: disable=import-outside-toplevel
init_scheduler(flask_app)
@flask_app.route('/debug-sentry')
def trigger_error():
division_by_zero = 1 / 0
# Register blueprints
from . import views # pylint: disable=import-outside-toplevel
flask_app.register_blueprint(views.bp, url_prefix='')
# a simple page that says hello
@flask_app.route('/hello')
def hello():
return _('Hello, World!')
from .auth import init_auth
init_auth(flask_app)
from .notification import init_notification
init_notification(flask_app)
from .currency import init_currency
init_currency(flask_app)
from . import items
flask_app.register_blueprint(items.bp)
flask_app.register_blueprint(items.api)
flask_app.add_url_rule('/', endpoint='index')
return flask_app
......@@ -47,17 +99,9 @@ def create_app(config: Optional[Dict] = None) -> Flask:
load_dotenv()
# Create the Flask application.
app = create_app()
flask_app = create_app()
# Initialize "rich" output if enabled. It produces more human readable logs.
# You need to install `flask-rich` to use this.
if app.config.get("RICH_LOGGING"):
from flask_rich import RichApplication
RichApplication(app)
app.logger.info("Using [blue]rich[/blue] interface for logging")
@app.route("/server-info")
@flask_app.route("/server-info")
def server_info() -> Response:
"""
A simple endpoint for checking the status of the server.
......@@ -66,7 +110,29 @@ def server_info() -> Response:
running correctly.
"""
# Test for database connection
database_ping = False
try:
from .db import db # pylint: disable=import-outside-toplevel
database_ping = db.connection.admin.command('ping').get("ok", False) and True
except Exception as exc: # pylint: disable=broad-except
logger.warning("Error querying mongodb server: %r", exc,
exc_info=True,
extra=flask_app.config.get_namespace("MONGODB"))
# Check for sentry
sentry_available = False
try:
from sentry_sdk import Hub
sentry_available = True if Hub.current.client else False
except ImportError:
logger.warning("Sentry package is not installed")
except TypeError:
logger.info("Sentry is not integrated")
response = {
"database_connectable": database_ping,
'sentry_available': sentry_available,
"version": get_version(),
"build_date": environ.get("BUILD_DATE", None)
}
......
from datetime import datetime
import functools
import logging
from flask import (
Blueprint, flash, redirect, render_template, request, session, url_for, abort
)
from flask_login import (
LoginManager,
login_user,
logout_user,
login_required,
current_user,
)
from flask_babel import _
from babel.dates import get_timezone
from werkzeug.security import check_password_hash, generate_password_hash
from sentry_sdk import set_user
from .models import AccessToken, Bid, User, Item
from mongoengine import DoesNotExist
from mongoengine.queryset.visitor import Q
bp = Blueprint('auth', __name__, url_prefix='/auth')
logger = logging.getLogger(__name__)
def init_auth(app):
"""
Integrate authentication into the application.
"""
app.register_blueprint(bp)
login_manager = LoginManager()
login_manager.login_view = 'auth.login'
login_manager.user_loader(load_logged_in_user)
app.config['AUTH_HEADER_NAME'] = 'Authorization'
login_manager.request_loader(load_user_from_request)
login_manager.init_app(app)
logger.debug("Initialized authentication")
def load_user_from_request(request):
"""
Load a user from the request.
This function is used by Flask-Login to load a user from the request.
"""
api_key = request.headers.get("Authorization")
if api_key:
api_key = api_key.replace("Bearer ", "", 1)
try:
token = AccessToken.objects.get(token=api_key)
if token.expires and token.expires < datetime.utcnow():
logger.warning("Token expired: %s", api_key)
return None
# User is authenticated
token.last_used_at = datetime.utcnow()
token.save()
logger.debug("User authenticated via token: %r", token.user.email, extra={
"user": token.user.email,
"user_id": str(token.user.id),
"token": token.token,
})
return token.user
except DoesNotExist:
logger.error("Token not found: %s", api_key)
return None
def load_logged_in_user(user_id):
"""
Load a user from the database, given the user's id.
"""
try:
user = User.objects.get(id=user_id)
set_user({"id": str(user.id), "email": user.email})
except DoesNotExist:
logger.error("User not found: %s", user_id)
return None
return user
def get_user_by_email(email: str) -> User:
"""
Get a user from the database, given the user's email.
If the email is 'me', then the current user is returned.
:param email: The email of the user to get.
"""
if email is None:
abort(404)
if email == "me" and current_user.is_authenticated:
email = current_user.email
try:
user = User.objects.get_or_404(email=email)
except DoesNotExist:
logger.error("User not found: %s", email)
abort(404)
return user
@bp.route('/register', methods=('GET', 'POST'))
def register():
if request.method == 'POST':
print("Registering user...")
email = request.form['email']
password = request.form['password']
password2 = request.form['password2']
terms = request.form.get('terms', False)
error = None
if not email:
error = 'Username is required.'
elif not password:
error = 'Password is required.'
elif password != password2:
error = 'Passwords do not match.'
elif not terms:
error = 'You must agree to the terms.'
timezone = None
try:
if user_tz := request.form.get('timezone', None):
timezone = get_timezone(user_tz).zone
except Exception as exc:
logger.debug("Error getting timezone from user data: %s", exc)
timezone = None
if error is None:
try:
user = User(
email=email,
password=generate_password_hash(password),
timezone=timezone,
)
user.save()
flash("You have been registered. Please log in.")
except Exception as exc:
error = f"Error creating user: {exc!s}"
else:
return redirect(url_for("auth.login"))
print("Could not register user:", error)
flash(error)
return render_template('auth/register.html')
@bp.route('/login', methods=('GET', 'POST'))
def login():
if request.method == 'POST':
email = request.form['email']
password = request.form['password']
user = None
error = None
try:
user = User.objects.get(email=email)
except DoesNotExist:
error = 'Incorrect username.'
if user is None:
error = 'Incorrect username.'
elif not check_password_hash(user['password'], password):
error = 'Incorrect password.'
if error is None:
remember_me = bool(request.form.get("remember-me", False))
if login_user(user, remember=remember_me):
flash(f"Hello {email}, You have been logged in.")
next = request.args.get('next')
# Better check that the user actually clicked on a relative link
# or else they could redirect you to a malicious website!
if next is None or not next.startswith('/'):
next = url_for('index')
return redirect(next)
else:
error = "Error logging in."
logger.info("Error logging user in: %r: Error: %s", email, error)
flash(error)
return render_template('auth/login.html')
@bp.route('/logout')
@login_required
def logout():
"""
Log out the current user.
Also removes the "remember me" cookie.
"""
logout_user()
flash("You have been logged out.")
return redirect(url_for('index'))
@bp.route('/profile', defaults={'email': 'me'})
@bp.route('/profile/<email>')
@login_required
def profile(email):
"""
Show the user's profile page for the given email.
If the email is 'me', then the current user's profile is shown.
"""
user: User = get_user_by_email(email)
# List the items user has created
items = Item.objects(seller=user).all()
# List the items user has won
# TODO: Could be done smarter with a join
bids = Bid.objects(bidder=user).only("id").all()
won_items = Item.objects(winning_bid__in=bids).all()
return render_template('auth/profile.html', user=user, items=items, won_items=won_items)
@bp.route('/profile/<email>/token', methods=('GET', 'POST'), defaults={'email': 'me'})
@login_required
def user_access_tokens(email):
"""
Show the user's tokens page for the given email.
"""
user: User = get_user_by_email(email)
# Fetch all the user tokens that are active or have no expire date
tokens = AccessToken.objects(Q(expires__gte=datetime.now()) | Q(expires=None), user=user).all()
token = None
if request.method == 'POST':
try:
name = request.form['name']
if expires := request.form.get('expires'):
expires = datetime.fromisoformat(expires)
else:
expires = None
token = AccessToken(
user=user,
name=name,
expires=expires,
)
token.save()
except KeyError as exc:
logger.debug("Missing required field: %s", exc)
flash(_("Required field missing"))
except Exception as exc:
logger.exception("Error creating token: %s", exc)
flash(_("Error creating token: %s") % exc)
else:
flash(_("Created token: %s") % token.name)
return render_template('auth/tokens.html', user=user, tokens=tokens, token=token)
@bp.route('/profile/<email>/token/<id>', methods=('POST',))
def delete_user_access_token(email, id):
"""
Delete an access token.
"""
user = get_user_by_email(email)
token = AccessToken.objects.get_or_404(id=id)
if token.user != user:
logger.warning("User %s tried to delete token %s", user.email, token.name, extra={
"user": user.email,
"token": str(token.id),
"token_user": token.user.email,
})
abort(403)
token.delete()
flash(f"Deleted token {token.name}")
return redirect(url_for('auth.user_access_tokens', email=token.user.email))