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 %}