Added OIDC IAM logic for Keycloak
This commit is contained in:
parent
3fd6b0a8e4
commit
7106d1cf18
|
@ -0,0 +1,40 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
. CONFIG.sh
|
||||||
|
|
||||||
|
# set -o xtrace ## To debug scripts
|
||||||
|
# set -o errexit ## To exit on error
|
||||||
|
# set -o errunset ## To exit if a variable is referenced but not set
|
||||||
|
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
rm -rf venv/
|
||||||
|
|
||||||
|
clear
|
||||||
|
python -m venv venv/
|
||||||
|
sleep 2
|
||||||
|
source "./venv/bin/activate"
|
||||||
|
|
||||||
|
ANSR="-1"
|
||||||
|
while [[ $ANSR != "0" ]] && [[ $ANSR != "1" ]] && [[ $ANSR != "2" ]]; do
|
||||||
|
clear
|
||||||
|
menu_mesage
|
||||||
|
read -p "--> : " ANSR
|
||||||
|
done
|
||||||
|
case $ANSR in
|
||||||
|
"1" ) pip install -r linux-requirements.txt;;
|
||||||
|
"2" ) pip install -r windows-requirements.txt;;
|
||||||
|
"0" ) exit;;
|
||||||
|
* ) echo "Don't know how you got here but that's a bad sign...";;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
function menu_mesage() {
|
||||||
|
echo "NOTE: Make sure to have Python 3 installed!"
|
||||||
|
echo -e "\nWhat do you want to do?"
|
||||||
|
echo -e "\t1) Generate Linux/Mac supported venv. (Installs Repuirements)"
|
||||||
|
echo -e "\t2) Generate Windows supported venv. (Installs Repuirements)"
|
||||||
|
echo -e "\t0) EXIT"
|
||||||
|
}
|
||||||
|
|
||||||
|
main $@;
|
|
@ -1,18 +1,10 @@
|
||||||
bcrypt==3.1.7
|
|
||||||
cffi==1.14.0
|
|
||||||
Click==7.0
|
|
||||||
Flask==1.1.1
|
Flask==1.1.1
|
||||||
Flask-Bcrypt==0.7.1
|
flask-oidc==1.4.0
|
||||||
Flask-Login==0.5.0
|
Flask-Login==0.5.0
|
||||||
|
Flask-Bcrypt==0.7.1
|
||||||
Flask-SQLAlchemy==2.4.1
|
Flask-SQLAlchemy==2.4.1
|
||||||
Flask-WTF==0.14.3
|
Flask-WTF==0.14.3
|
||||||
gunicorn==19.9.0
|
gunicorn==19.9.0
|
||||||
itsdangerous==1.1.0
|
|
||||||
Jinja2==2.10.3
|
|
||||||
MarkupSafe==1.1.1
|
|
||||||
pkg-resources==0.0.0
|
|
||||||
pycparser==2.20
|
|
||||||
six==1.14.0
|
|
||||||
SQLAlchemy==1.3.11
|
SQLAlchemy==1.3.11
|
||||||
Werkzeug==0.16.0
|
Werkzeug==0.16.0
|
||||||
WTForms==2.2.1
|
WTForms==2.2.1
|
||||||
|
|
|
@ -1,9 +1,13 @@
|
||||||
# Python imports
|
# Python imports
|
||||||
import secrets
|
import os, secrets
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
|
||||||
# Lib imports
|
# Lib imports
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
#OIDC Login path
|
||||||
|
from flask_oidc import OpenIDConnect
|
||||||
|
# Flask Login Path
|
||||||
from flask_bcrypt import Bcrypt
|
from flask_bcrypt import Bcrypt
|
||||||
from flask_login import current_user, login_user, logout_user, LoginManager
|
from flask_login import current_user, login_user, logout_user, LoginManager
|
||||||
|
|
||||||
|
@ -11,21 +15,43 @@ from flask_login import current_user, login_user, logout_user, LoginManager
|
||||||
# Apoplication imports
|
# Apoplication imports
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Configs and 'init'
|
# Configs and 'init'
|
||||||
|
ROOT_FILE_PTH = os.path.dirname(os.path.realpath(__file__))
|
||||||
|
# This path is submitted as the redirect URI in certain code flows.
|
||||||
|
# Change localhost%3A6969 to different port accordingly or change to your domain.
|
||||||
|
REDIRECT_LINK = "http%3A%2F%2Flocalhost%3A6969%2F"
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config['SQLALCHEMY_DATABASE_URI'] = "sqlite:///static/db/database.db"
|
app.config.update({
|
||||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
"TITLE": ':::APP TITLE:::',
|
||||||
app.config['TITLE'] = ':::APP TITLE:::'
|
'DEBUG': False,
|
||||||
|
'LOGIN_PATH': "FLASK_LOGIN", # Value can be OIDC or FLASK_LOGIN
|
||||||
# For csrf and some other stuff...
|
'SECRET_KEY': secrets.token_hex(32), # For csrf and some other stuff...
|
||||||
app.config['SECRET_KEY'] = secrets.token_hex(32)
|
'PERMANENT_SESSION_LIFETIME': timedelta(days = 7).total_seconds(),
|
||||||
|
'SQLALCHEMY_DATABASE_URI': "sqlite:///static/db/database.db",
|
||||||
|
'SQLALCHEMY_TRACK_MODIFICATIONS': False,
|
||||||
|
'APP_REDIRECT_URI': REDIRECT_LINK,
|
||||||
|
'OIDC_CLIENT_SECRETS': ROOT_FILE_PTH + '/client_secrets.json',
|
||||||
|
'OIDC_ID_TOKEN_COOKIE_SECURE': True, # Only set false in development setups...
|
||||||
|
'OIDC_REQUIRE_VERIFIED_EMAIL': False,
|
||||||
|
'OIDC_USER_INFO_ENABLED': True,
|
||||||
|
'OIDC_VALID_ISSUERS': [
|
||||||
|
'http://localhost:8080/auth/realms/apps',
|
||||||
|
'https://localhost:443/auth/realms/apps'
|
||||||
|
],
|
||||||
|
'OIDC_TOKEN_TYPE_HINT': 'access_token'
|
||||||
|
})
|
||||||
|
|
||||||
|
oidc = OpenIDConnect(app)
|
||||||
login_manager = LoginManager(app)
|
login_manager = LoginManager(app)
|
||||||
bcrypt = Bcrypt(app)
|
bcrypt = Bcrypt(app)
|
||||||
|
|
||||||
|
|
||||||
from core.models import db, User
|
from core.models import db, User
|
||||||
db.init_app(app)
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
|
||||||
|
|
||||||
from core.forms import RegisterForm, LoginForm
|
from core.forms import RegisterForm, LoginForm
|
||||||
from core import routes
|
from core import routes
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"web": {
|
||||||
|
"auth_uri": "http://localhost:8080/auth/realms/apps/protocol/openid-connect/auth",
|
||||||
|
"client_id": "apps",
|
||||||
|
"issuer": "http://localhost:8080/auth/realms/apps",
|
||||||
|
"client_secret": "[ADD YOUR SECRET FROM THE REALM>CLIENTS>apps>Credentials Tab]",
|
||||||
|
"redirect_uris": [
|
||||||
|
"http://localhost:6969/"
|
||||||
|
],
|
||||||
|
"userinfo_uri": "http://localhost:8080/auth/realms/apps/protocol/openid-connect/userinfo",
|
||||||
|
"token_uri": "http://localhost:8080/auth/realms/apps/protocol/openid-connect/token",
|
||||||
|
"token_introspection_uri": "http://localhost:8080/auth/realms/apps/protocol/openid-connect/token/introspect"
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,7 +4,6 @@ from wtforms.validators import DataRequired, Length, Email, EqualTo, ValidationE
|
||||||
from core import User
|
from core import User
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class RegisterForm(FlaskForm):
|
class RegisterForm(FlaskForm):
|
||||||
username = StringField('Username', validators=[DataRequired(), Length(min=4, max=24)])
|
username = StringField('Username', validators=[DataRequired(), Length(min=4, max=24)])
|
||||||
email = StringField('Email', validators=[DataRequired(), Email()])
|
email = StringField('Email', validators=[DataRequired(), Email()])
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
from core import app, login_manager
|
from core import app, login_manager
|
||||||
from flask_login import UserMixin
|
from flask_login import UserMixin
|
||||||
|
|
|
@ -5,7 +5,7 @@ from flask import request, render_template
|
||||||
|
|
||||||
# App imports
|
# App imports
|
||||||
from core import app, db # Get from __init__
|
from core import app, db # Get from __init__
|
||||||
from core.MessageHandler import MessageHandler # Get simple message processor
|
from core.utils import MessageHandler # Get simple message processor
|
||||||
|
|
||||||
|
|
||||||
msgHandler = MessageHandler()
|
msgHandler = MessageHandler()
|
||||||
|
|
|
@ -1,2 +1,6 @@
|
||||||
from . import Routes
|
from . import Routes
|
||||||
from .pages import Login, Register
|
from .pages import Flask_Login
|
||||||
|
from .pages import Flask_Register
|
||||||
|
from .pages import OIDC_Login
|
||||||
|
from .pages import OIDC_Register
|
||||||
|
from .pages import LoginManager
|
||||||
|
|
|
@ -6,14 +6,14 @@ from flask_login import current_user, login_user, logout_user
|
||||||
|
|
||||||
# App imports
|
# App imports
|
||||||
from core import app, bcrypt, db, User, LoginForm
|
from core import app, bcrypt, db, User, LoginForm
|
||||||
from core.MessageHandler import MessageHandler # Get simple message processor
|
from core.utils import MessageHandler # Get simple message processor
|
||||||
|
|
||||||
|
|
||||||
msgHandler = MessageHandler()
|
msgHandler = MessageHandler()
|
||||||
TITLE = app.config['TITLE']
|
TITLE = app.config['TITLE']
|
||||||
|
|
||||||
@app.route('/login', methods=['GET', 'POST'])
|
@app.route('/app-login', methods=['GET', 'POST'])
|
||||||
def login():
|
def app_login():
|
||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
return redirect(url_for("home"))
|
return redirect(url_for("home"))
|
||||||
|
|
||||||
|
@ -31,8 +31,8 @@ def login():
|
||||||
return render_template('login.html', title=TITLE, form=_form)
|
return render_template('login.html', title=TITLE, form=_form)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/logout')
|
@app.route('/app-logout')
|
||||||
def logout():
|
def app_logout():
|
||||||
logout_user()
|
logout_user()
|
||||||
flash("Logged out successfully!", "success")
|
flash("Logged out successfully!", "success")
|
||||||
return redirect(url_for("home"))
|
return redirect(url_for("home"))
|
|
@ -6,14 +6,14 @@ from flask import request, render_template, url_for, redirect, flash
|
||||||
# App imports
|
# App imports
|
||||||
from core import app, bcrypt, db, current_user, RegisterForm # Get from __init__
|
from core import app, bcrypt, db, current_user, RegisterForm # Get from __init__
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from core.MessageHandler import MessageHandler # Get simple message processor
|
from core.utils import MessageHandler # Get simple message processor
|
||||||
|
|
||||||
|
|
||||||
msgHandler = MessageHandler()
|
msgHandler = MessageHandler()
|
||||||
TITLE = app.config['TITLE']
|
TITLE = app.config['TITLE']
|
||||||
|
|
||||||
@app.route('/register', methods=['GET', 'POST'])
|
@app.route('/app-register', methods=['GET', 'POST'])
|
||||||
def register():
|
def app_register():
|
||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
return redirect(url_for("home"))
|
return redirect(url_for("home"))
|
||||||
|
|
|
@ -0,0 +1,43 @@
|
||||||
|
# Python imports
|
||||||
|
|
||||||
|
# Lib imports
|
||||||
|
from flask import redirect, url_for, flash
|
||||||
|
|
||||||
|
# App imports
|
||||||
|
from core import app
|
||||||
|
|
||||||
|
|
||||||
|
ROUTE = app.config['LOGIN_PATH']
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/login', methods=['GET', 'POST'])
|
||||||
|
def login():
|
||||||
|
if ROUTE == "OIDC":
|
||||||
|
return redirect(url_for("oidc_login"))
|
||||||
|
if ROUTE == "FLASK_LOGIN":
|
||||||
|
return redirect(url_for("app_login"))
|
||||||
|
|
||||||
|
flash("No Login Path Accessable! Please contact an Administrator!", "danger")
|
||||||
|
return redirect(url_for("home"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/logout')
|
||||||
|
def logout():
|
||||||
|
if ROUTE == "OIDC":
|
||||||
|
return redirect(url_for("oidc_logout"))
|
||||||
|
if ROUTE == "FLASK_LOGIN":
|
||||||
|
return redirect(url_for("app_logout"))
|
||||||
|
|
||||||
|
flash("No Logout Path Accessable! Please contact an Administrator!", "danger")
|
||||||
|
return redirect(url_for("home"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/register', methods=['GET', 'POST'])
|
||||||
|
def register():
|
||||||
|
if ROUTE == "OIDC":
|
||||||
|
return redirect(url_for("oidc_register"))
|
||||||
|
if ROUTE == "FLASK_LOGIN":
|
||||||
|
return redirect(url_for("app_register"))
|
||||||
|
|
||||||
|
flash("No Register Path Accessable! Please contact an Administrator!", "danger")
|
||||||
|
return redirect(url_for("home"))
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Python imports
|
||||||
|
|
||||||
|
# Lib imports
|
||||||
|
from flask import request, redirect, flash
|
||||||
|
|
||||||
|
|
||||||
|
# App imports
|
||||||
|
from ... import app, oidc
|
||||||
|
|
||||||
|
|
||||||
|
TITLE = app.config['TITLE']
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/oidc-login', methods=['GET', 'POST'])
|
||||||
|
@oidc.require_login
|
||||||
|
def oidc_login():
|
||||||
|
return redirect("/")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/oidc-logout', methods=['GET', 'POST'])
|
||||||
|
@oidc.require_login
|
||||||
|
def oidc_logout():
|
||||||
|
oidc.logout()
|
||||||
|
flash("Logged out successfully!", "success")
|
||||||
|
# NOTE: Need to redirect to logout on OIDC server to end session there too.
|
||||||
|
# If not, we can hit login url again and get same token until it expires.
|
||||||
|
return redirect( oidc.client_secrets.get('issuer')
|
||||||
|
+ '/protocol/openid-connect/logout?redirect_uri='
|
||||||
|
+ app.config['APP_REDIRECT_URI'])
|
|
@ -0,0 +1,32 @@
|
||||||
|
# Python imports
|
||||||
|
|
||||||
|
# Lib imports
|
||||||
|
from flask import request, render_template, url_for, redirect, flash
|
||||||
|
|
||||||
|
# App imports
|
||||||
|
from ... import app, oidc, db # Get from __init__
|
||||||
|
from ...utils import MessageHandler # Get simple message processor
|
||||||
|
|
||||||
|
|
||||||
|
msgHandler = MessageHandler()
|
||||||
|
TITLE = app.config['TITLE']
|
||||||
|
|
||||||
|
@app.route('/oidc-register', methods=['GET', 'POST'])
|
||||||
|
def oidc_register():
|
||||||
|
if oidc.user_loggedin:
|
||||||
|
return redirect("/home")
|
||||||
|
|
||||||
|
_form = RegisterForm()
|
||||||
|
if _form.validate_on_submit():
|
||||||
|
# NOTE: Do a requests api here maybe??
|
||||||
|
|
||||||
|
# hashed_password = bcrypt.generate_password_hash(_form.password.data).decode("utf-8")
|
||||||
|
# user = User(username=_form.username.data, password=hashed_password)
|
||||||
|
# db.session.add(user)
|
||||||
|
# db.session.commit()
|
||||||
|
flash("Account created successfully!", "success")
|
||||||
|
return redirect("/login")
|
||||||
|
|
||||||
|
return render_template('register.html',
|
||||||
|
title = TITLE,
|
||||||
|
form = _form)
|
Binary file not shown.
|
@ -0,0 +1,56 @@
|
||||||
|
# Python imports
|
||||||
|
import os, logging
|
||||||
|
|
||||||
|
# Application imports
|
||||||
|
|
||||||
|
|
||||||
|
class Logger:
|
||||||
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_logger(self, loggerName = "NO_LOGGER_NAME_PASSED", createFile = True):
|
||||||
|
"""
|
||||||
|
Create a new logging object and return it.
|
||||||
|
:note:
|
||||||
|
NOSET # Don't know the actual log level of this... (defaulting or literally none?)
|
||||||
|
Log Levels (From least to most)
|
||||||
|
Type Value
|
||||||
|
CRITICAL 50
|
||||||
|
ERROR 40
|
||||||
|
WARNING 30
|
||||||
|
INFO 20
|
||||||
|
DEBUG 10
|
||||||
|
:param loggerName: Sets the name of the logger object. (Used in log lines)
|
||||||
|
:param createFile: Whether we create a log file or just pump to terminal
|
||||||
|
|
||||||
|
:return: the logging object we created
|
||||||
|
"""
|
||||||
|
|
||||||
|
globalLogLvl = logging.DEBUG # Keep this at highest so that handlers can filter to their desired levels
|
||||||
|
chLogLevel = logging.CRITICAL # Prety musch the only one we change ever
|
||||||
|
fhLogLevel = logging.DEBUG
|
||||||
|
log = logging.getLogger(loggerName)
|
||||||
|
log.setLevel(globalLogLvl)
|
||||||
|
|
||||||
|
# Set our log output styles
|
||||||
|
fFormatter = logging.Formatter('[%(asctime)s] %(pathname)s:%(lineno)d %(levelname)s - %(message)s', '%m-%d %H:%M:%S')
|
||||||
|
cFormatter = logging.Formatter('%(pathname)s:%(lineno)d] %(levelname)s - %(message)s')
|
||||||
|
|
||||||
|
ch = logging.StreamHandler()
|
||||||
|
ch.setLevel(level=chLogLevel)
|
||||||
|
ch.setFormatter(cFormatter)
|
||||||
|
log.addHandler(ch)
|
||||||
|
|
||||||
|
if createFile:
|
||||||
|
folder = "logs"
|
||||||
|
file = folder + "/flask-application.log"
|
||||||
|
|
||||||
|
if not os.path.exists(folder):
|
||||||
|
os.mkdir(folder)
|
||||||
|
|
||||||
|
fh = logging.FileHandler(file)
|
||||||
|
fh.setLevel(level=fhLogLevel)
|
||||||
|
fh.setFormatter(fFormatter)
|
||||||
|
log.addHandler(fh)
|
||||||
|
|
||||||
|
return log
|
|
@ -0,0 +1,2 @@
|
||||||
|
from .Logger import Logger
|
||||||
|
from .MessageHandler import MessageHandler
|
|
@ -10,7 +10,8 @@ function main() {
|
||||||
cd "${SCRIPTPATH}"
|
cd "${SCRIPTPATH}"
|
||||||
echo "Working Dir: " $(pwd)
|
echo "Working Dir: " $(pwd)
|
||||||
source "../venv/bin/activate"
|
source "../venv/bin/activate"
|
||||||
# Note can replace 127.0.0.1 with 0.0.0.0 to make it 'network/internet' accessable...
|
# Note: Can replace 127.0.0.1 with 0.0.0.0 to make it 'network/internet' accessable...
|
||||||
gunicorn wsgi:app -b 127.0.0.1:8080 # <module>:<app> IE <file>:<flask app variable>
|
# Note 2: Keycloak uses 8080. Change it or keep this as is.
|
||||||
|
gunicorn wsgi:app -b 127.0.0.1:6969 # <module>:<app> IE <file>:<flask app variable>
|
||||||
}
|
}
|
||||||
main $@;
|
main $@;
|
||||||
|
|
|
@ -9,7 +9,7 @@ function main() {
|
||||||
SCRIPTPATH="$( cd "$(dirname "")" >/dev/null 2>&1 ; pwd -P )"
|
SCRIPTPATH="$( cd "$(dirname "")" >/dev/null 2>&1 ; pwd -P )"
|
||||||
cd "${SCRIPTPATH}"
|
cd "${SCRIPTPATH}"
|
||||||
echo "Working Dir: " $(pwd)
|
echo "Working Dir: " $(pwd)
|
||||||
source "../venv/Scripts/activate"
|
source "../venv/bin/activate"
|
||||||
gunicorn --bind unix:/tmp/app.sock wsgi:app
|
gunicorn --bind unix:/tmp/app.sock wsgi:app
|
||||||
}
|
}
|
||||||
main $@;
|
main $@;
|
||||||
|
|
|
@ -7,7 +7,8 @@
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
source "../venv/Scripts/activate"
|
source "../venv/Scripts/activate"
|
||||||
# Note can replace 127.0.0.1 with 0.0.0.0 to make it 'network/internet' accessable...
|
# Note: Can replace 127.0.0.1 with 0.0.0.0 to make it 'network/internet' accessable...
|
||||||
waitress-serve --listen=127.0.0.1:8080 wsgi:app # <module>:<app> IE <file>:<flask app variable>
|
# Note 2: Keycloak uses 8080. Change it or keep this as is.
|
||||||
|
waitress-serve --listen=127.0.0.1:6969 wsgi:app # <module>:<app> IE <file>:<flask app variable>
|
||||||
}
|
}
|
||||||
main $@;
|
main $@;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
from core import app
|
from core import app
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app.run(debug=True)
|
app.run(debug=False)
|
||||||
|
|
|
@ -1,18 +1,10 @@
|
||||||
bcrypt==3.1.7
|
|
||||||
cffi==1.14.0
|
|
||||||
Click==7.0
|
|
||||||
Flask==1.1.1
|
Flask==1.1.1
|
||||||
Flask-Bcrypt==0.7.1
|
flask-oidc==1.4.0
|
||||||
Flask-Login==0.5.0
|
Flask-Login==0.5.0
|
||||||
|
Flask-Bcrypt==0.7.1
|
||||||
Flask-SQLAlchemy==2.4.1
|
Flask-SQLAlchemy==2.4.1
|
||||||
Flask-WTF==0.14.3
|
Flask-WTF==0.14.3
|
||||||
itsdangerous==1.1.0
|
|
||||||
Jinja2==2.10.3
|
|
||||||
MarkupSafe==1.1.1
|
|
||||||
pkg-resources==0.0.0
|
|
||||||
pycparser==2.20
|
|
||||||
six==1.14.0
|
|
||||||
SQLAlchemy==1.3.11
|
|
||||||
waitress==1.4.3
|
waitress==1.4.3
|
||||||
|
SQLAlchemy==1.3.11
|
||||||
Werkzeug==0.16.0
|
Werkzeug==0.16.0
|
||||||
WTForms==2.2.1
|
WTForms==2.2.1
|
Loading…
Reference in New Issue