commit 49b228c1a53f5f9b53e35fa83987ae428b39b54d Author: Edward Betts <edward@4angle.com> Date: Sun Jan 21 09:10:07 2024 +0000 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24e7b0e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__ +config diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f6d2506 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Edward Betts <edward@4angle.com> + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d81ffb6 --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# UniAuth + +UniAuth is a single sign-on application designed to provide a secure and efficient method for managing user authentication. The application is built using Flask, a popular web framework in Python, and offers a straightforward implementation suitable for integrating into various web services. + +## Features + +- **Token-Based Authentication**: Generates and verifies secure authentication tokens. +- **Login Required Decorator**: Enhances routes to require user authentication. +- **User Authentication**: Validates username and password against stored credentials. +- **Secure Cookie Handling**: Sets authentication tokens in cookies with security best practices. + +## Getting Started + +These instructions will get you a copy of the project up and running on your local machine for development and testing purposes. + +### Prerequisites + +- Python 3 +- Flask +- Werkzeug +- ItsDangerous + +### Installing + +1. Clone the repository: + ``` + git clone https://git.4angle.com/edward/UniAuth + ``` +2. Install the required packages: + ``` + pip install flask werkzeug itsdangerous + ``` +3. Set up the configuration in `config/default.py`. + +### Running the Application + +Run the application using: + +``` +python main.py +``` + +The application should now be running on `http://0.0.0.0:5000/`. + +## Usage + +- **Login**: Users can log in by providing their username and password. +- **Token Generation**: Upon successful login, a secure token is generated and stored in a cookie. +- **Access Protected Routes**: Only authenticated users can access protected routes. + +## Contributing + +Contributions are welcome. If you'd like to contribute to this project, please +follow these steps: + +1. Fork the repository. +2. Create a new branch for your feature or bug fix. +3. Make your changes and commit them with descriptive messages. +4. Push your changes to your fork. +5. Create a pull request to the main repository's `main` branch. + +## License + +This project is licensed under the MIT License. Feel free to use, modify, and distribute it as per the license terms. See the [LICENSE](LICENSE) file for details. + +## Acknowledgments + +- Flask team for the great web framework +- ItsDangerous for secure token generation + +--- + +This README provides a basic overview and guide for your application. You can further customize it to include more detailed documentation as needed. diff --git a/main.py b/main.py new file mode 100755 index 0000000..2eb5400 --- /dev/null +++ b/main.py @@ -0,0 +1,103 @@ +#!/usr/bin/python3 +"""Single sign-on application.""" + +import functools +import typing +from datetime import datetime, timedelta +from typing import Any, Callable + +import flask +import werkzeug +from itsdangerous.url_safe import URLSafeTimedSerializer + +app = flask.Flask(__name__) +app.debug = True +app.config.from_object("config.default") + +serializer = URLSafeTimedSerializer(app.config["SECRET_KEY"]) +max_age = 60 * 60 * 24 * 90 + + +def generate_auth_token(username: str) -> str: + """Generate a secure authentication token.""" + token = typing.cast(str, serializer.dumps(username, salt="auth")) + assert isinstance(token, str) + return token + + +def verify_auth_token(token: str) -> str | None: + """Verify the authentication token.""" + try: + username = serializer.loads(token, salt="auth", max_age=max_age) + except Exception: + return None + + assert isinstance(username, str) + return username + + +def login_required(f: Callable[..., Any]) -> Callable[..., Any]: + """Route requires login decorator.""" + + @functools.wraps(f) + def decorated_function(*args, **kwargs) -> werkzeug.Response: # type: ignore + token = flask.request.cookies.get("auth_token") + if not token or verify_auth_token(token) is None: + # Save the original URL in the session and redirect to login + flask.session["next"] = flask.request.url + return flask.redirect(flask.url_for("login_page")) + return typing.cast(werkzeug.Response, f(*args, **kwargs)) + + return decorated_function + + +@app.route("/") +@login_required +def root_page() -> str: + """Root page.""" + return flask.render_template("auth_good.html") + + +def check_user_auth() -> dict[str, Any] | None: + """Load username and password and check if valid.""" + # Extract username and password from POST request + username = flask.request.form.get("username", type=str) + password = flask.request.form.get("password", type=str) + + # Retrieve users from app config + users = app.config["USERS"] + + # Check if the username and password match any user in the list + return next( + (u for u in users if u["username"] == username and u["password"] == password), + None, + ) + + +@app.route("/login", methods=["GET", "POST"]) +def login_page() -> str | werkzeug.Response: + """Login page.""" + if flask.request.method == "GET": + return flask.render_template("login.html") + + if not (user := check_user_auth()): + # Login failed: Show an error message on the login page + return flask.render_template("login.html", error="Invalid credentials") + + expire_date = datetime.now() + timedelta(days=180) + + response = flask.redirect(flask.session.get("next") or flask.url_for("dashboard")) + response.set_cookie( + "auth_token", + generate_auth_token(user["username"]), + expires=expire_date, + httponly=True, + secure=True, + samesite="Lax", + ) + + return response + + +if __name__ == "__main__": + app.run(host="0.0.0.0") diff --git a/templates/auth_good.html b/templates/auth_good.html new file mode 100644 index 0000000..52a2368 --- /dev/null +++ b/templates/auth_good.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} + +{% block content %} +<div class="container-fluid mt-2"> + <h1>Auth</h1> + <p>Login successful.</p> +</div> +{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..7b1cbde --- /dev/null +++ b/templates/base.html @@ -0,0 +1,23 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta http-equiv="X-UA-Compatible" content="IE=edge"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>{% block title %}{% endblock %}</title> + <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📅</text></svg>"> + + <link href="https://unpkg.com/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous"> + +{% block style %} +{% endblock %} +</head> +{% from "navbar.html" import navbar with context %} + +<body> +{% block nav %}{{ navbar() }}{% endblock %} +{% block content %}{% endblock %} +{% block scripts %}{% endblock %} +<script src="https://unpkg.com/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script> +</body> +</html> diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..4eb9fff --- /dev/null +++ b/templates/login.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} + +{% block content %} +<div class="container-fluid mt-2"> + <h1>Login</h1> + + {% if error %} + <div class="alert alert-danger" role="alert"> + {{ error }} + </div> + {% endif %} + + <form method="POST"> + <div class="mb-3"> + <label for="username" class="form-label">username</label> + <input class="form-control" id="username" name="username"> + </div> + <div class="mb-3"> + <label for="password" class="form-label">Password</label> + <input type="password" class="form-control" id="passwod" name="password"> + </div> + <button type="submit" class="btn btn-primary">Submit</button> + </form> + +</div> +{% endblock %} diff --git a/templates/navbar.html b/templates/navbar.html new file mode 100644 index 0000000..a9253f0 --- /dev/null +++ b/templates/navbar.html @@ -0,0 +1,31 @@ +{% macro navbar() %} + +{% set pages = [ + {"endpoint": "root_page", "label": "Home" }, + {"endpoint": "login_page", "label": "Login" }, +] %} +{% set project = "Auth" %} + + +<nav class="navbar navbar-expand-md bg-success" data-bs-theme="dark"> + <div class="container-fluid"> + <a class="navbar-brand" href="{{ url_for("root_page") }}">{{ project }}</a> + <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation"> + <span class="navbar-toggler-icon"></span> + </button> + <div class="collapse navbar-collapse" id="navbarSupportedContent"> + <ul class="navbar-nav me-auto mb-2 mb-lg-0"> + {% for page in pages %} + {% set is_active = request.endpoint == page.endpoint %} + <li class="nav-item"> + <a class="nav-link{% if is_active %} border border-white border-2 active{% endif %}" {% if is_active %} aria-current="page"{% endif %} href="{{ url_for(page.endpoint) }}"> + {{ page.label }} + </a> + </li> + {% endfor %} + </ul> + </div> + </div> +</nav> + +{% endmacro %}