├── .config └── dotnet-tools.json ├── .github ├── .agp └── workflows │ ├── release.yml │ └── sync_readme.yml ├── .gitignore ├── .vscode └── settings.json ├── Dockerfile ├── README.md ├── _config.yml ├── docker-compose.yml ├── examples ├── maximal_conf.yml └── minimal_conf.yml ├── paket.dependencies ├── preview ├── login.png └── logoff.png ├── release.sh ├── src ├── consts.fs ├── controllers │ └── authenticate.fs ├── entry.fs ├── env.fs ├── main.fsproj ├── middlewares │ ├── auth.fs │ └── rewrite.fs ├── models │ ├── auth.fs │ ├── jwt.fs │ └── ratelimit.fs ├── pages │ ├── layout.fs │ ├── login.fs │ └── logout.fs ├── paket.references ├── server.fs ├── state.fs └── views │ ├── assets │ ├── chess.bmp │ ├── go.png │ ├── key.png │ ├── shutdown.png │ ├── users.png │ └── xp.jpg │ ├── css │ ├── base.css │ ├── login.css │ └── logout.css │ └── js │ ├── login.js │ └── logout.js └── tests ├── entry.fs ├── paket.references └── tests.fsproj /.config/dotnet-tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "isRoot": true, 4 | "tools": { 5 | "paket": { 6 | "version": "5.245.2", 7 | "commands": [ 8 | "paket" 9 | ] 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /.github/.agp: -------------------------------------------------------------------------------- 1 | 2 | Auto Github Push (AGP) 3 | https://github.com/ms-jpq/auto-github-push 4 | 5 | --- 6 | 2023-08-22 05:28 7 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - xp 7 | schedule: 8 | - cron: "0 0 * * *" # daily 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v2 17 | 18 | - name: Build 19 | uses: docker/build-push-action@v1 20 | with: 21 | username: ${{ secrets.DOCKER_USERNAME }} 22 | password: ${{ secrets.DOCKER_PASSWORD }} 23 | repository: ${{ secrets.DOCKER_USERNAME }}/simple-traefik-identity 24 | tags: latest 25 | path: . 26 | Dockerfile: Dockerfile 27 | -------------------------------------------------------------------------------- /.github/workflows/sync_readme.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Sync Readme 3 | 4 | on: 5 | push: 6 | branches: 7 | - xp 8 | schedule: 9 | - cron: "0 0 * * *" # daily 10 | 11 | 12 | jobs: 13 | sync: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v2 19 | 20 | - name: Sync 21 | uses: ms-jpq/sync-dockerhub-readme@v1 22 | with: 23 | username: ${{ secrets.DOCKER_USERNAME }} 24 | password: ${{ secrets.DOCKER_PASSWORD }} 25 | repository: ${{ secrets.DOCKER_USERNAME }}/simple-traefik-identity 26 | readme: "./README.md" 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.git/ 2 | .DS_Store 3 | obj 4 | bin 5 | temp 6 | .ionide 7 | .paket/ 8 | paket-files/ 9 | paket.lock 10 | packages/ 11 | .fake 12 | build.fsx.lock 13 | .secrets 14 | .env 15 | node_modules 16 | package-lock.json 17 | out 18 | *.css.d.ts 19 | npm-debug.log 20 | config 21 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "FSharp.enableAnalyzers": true, 3 | "FSharp.enableTreeView": true, 4 | "FSharp.externalAutocomplete": true, 5 | "FSharp.fsacRuntime": "netcore" 6 | } 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS build 2 | 3 | WORKDIR /build 4 | 5 | RUN dotnet new tool-manifest && \ 6 | dotnet tool install Paket 7 | 8 | 9 | COPY paket.dependencies . 10 | RUN dotnet paket install && \ 11 | dotnet paket restore 12 | 13 | COPY src/views out/views 14 | COPY src src 15 | RUN dotnet publish \ 16 | -r linux-musl-x64 \ 17 | -c Release \ 18 | -o out \ 19 | /p:PublishSingleFile=true \ 20 | src/main.fsproj 21 | 22 | # 23 | # 24 | # 25 | FROM mcr.microsoft.com/dotnet/core/runtime-deps:3.1.0-alpine3.10 26 | 27 | WORKDIR /sti 28 | EXPOSE 5050 29 | 30 | 31 | COPY --from=build /build/out /sti 32 | 33 | 34 | 35 | ENTRYPOINT ["/sti/main"] 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Simple Traefik Identity](https://ms-jpq.github.io/simple-traefik-identity/) 2 | 3 | [![Docker Pulls](https://img.shields.io/docker/pulls/msjpq/simple-traefik-identity.svg)](https://hub.docker.com/r/msjpq/simple-traefik-identity/) 4 | 5 | Simple & Configurable -- SSO, for Traefik. 6 | 7 | ## Preview 8 | 9 | ### Logon 10 | 11 | ![login img](https://github.com/ms-jpq/simple-traefik-identity/raw/xp/preview/login.png) 12 | 13 | ### Logoff 14 | 15 | (if not authorized, you can login via another account) 16 | 17 | ![logoff img](https://github.com/ms-jpq/simple-traefik-identity/raw/xp/preview/logoff.png) 18 | 19 | ## Features 20 | 21 | ### Role Based Access Control (RBAC) 22 | 23 | ```yaml 24 | groups: 25 | - name: quebec 26 | sub_domains: 27 | - "*" 28 | - name: saskatchewan 29 | sub_domains: 30 | - canada.ca 31 | - www.tourismnewbrunswick.ca 32 | - name: newfoundland 33 | sub_domains: 34 | - www.gov.nu.ca 35 | 36 | users: 37 | - name: yukon 38 | password: yukon 39 | session: 0.5 # logs you out after half a day 40 | groups: 41 | - quebec 42 | - name: nunavut 43 | password: nunavut 44 | groups: 45 | - saskatchewan 46 | - newfoundland 47 | ``` 48 | 49 | ### Rate Limit 50 | 51 | ```yaml 52 | rate_limit: 53 | headers: 54 | - Cf-Connecting-Ip 55 | - Another-Header 56 | - So-on 57 | rate: 5 58 | timer: 30 59 | ``` 60 | 61 | ### Custom UI 62 | 63 | ```yaml 64 | display: 65 | title: Simple Traefik Identity 66 | background: |- 67 | https://github.com/ms-jpq/simple-traefik-identity/raw/xp/src/views/assets/xp.jpg 68 | ``` 69 | 70 | ## Usage 71 | 72 | See [minimal](https://github.com/ms-jpq/simple-traefik-identity/blob/xp/examples/minimal_conf.yml) and [maximal](https://github.com/ms-jpq/simple-traefik-identity/blob/xp/examples/maximal_conf.yml) to get started. 73 | 74 | ```yaml 75 | sti: 76 | image: msjpq/simple-traefik-identity 77 | container_name: sti 78 | labels: 79 | - traefik.http.services.sti.loadbalancer.server.port=5050 80 | - traefik.http.middlewares.auth.forwardauth.address=http://sti:5050 81 | - traefik.http.middlewares.auth.forwardauth.authResponseHeaders=X-Forwarded-User 82 | volumes: 83 | - ./config/conf.yml:/sti/config/conf.yml 84 | ``` 85 | 86 | ## Security 87 | 88 | ```txt 89 | 👩‍💻 -------- Request --------> 👮‍♀️ 90 | 👩‍💻 <---- Auth Challenge ----- 👮‍♀️ 91 | 👩‍💻 ------ Credentials ------> 👮‍♀️ 92 | 👩‍💻 <-- Samesite JWT Cookie -- 👮‍♀️ 93 | ``` 94 | 95 | ```txt 96 | 👩‍💻 -- Samesite JWT Cookie --> 👮‍♀️ 97 | 👩‍💻 <---------- OK ----------- 👮‍♀️ 98 | 👩‍💻 -- Samesite JWT Cookie --> 👮‍♀️ 99 | 👩‍💻 <---------- OK ----------- 👮‍♀️ 100 | ``` 101 | 102 | JWT payload only contain list of accessible domains 103 | 104 | ## Sister 105 | 106 | Check out my sister: [Simple Traefik Dash](https://ms-jpq.github.io/simple-traefik-dash/) 107 | 108 | Zero conf service dashboard for Traefik v2 109 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | title: Simple Traefik Identity 3 | 4 | showcase: True 5 | 6 | images: 7 | - https://github.com/ms-jpq/simple-traefik-identity/raw/xp/preview/login.png 8 | - https://github.com/ms-jpq/simple-traefik-identity/raw/xp/preview/logoff.png 9 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | 3 | services: 4 | # 5 | # This file is used for development 6 | # 7 | sti: 8 | build: . 9 | container_name: sti 10 | labels: 11 | - traefik.http.routers.sti.entrypoints=traefik 12 | - traefik.http.services.sti.loadbalancer.server.port=5050 13 | - traefik.http.middlewares.auth.forwardauth.address=http://sti:5050 14 | - traefik.http.middlewares.auth.forwardauth.authResponseHeaders=X-Forwarded-User 15 | environment: 16 | - TZ=${TZ} 17 | ports: 18 | - 5050:5050 19 | volumes: 20 | - ./config/conf.yml:/sti/config/conf.yml 21 | 22 | traefik: 23 | image: traefik 24 | container_name: traefik 25 | environment: 26 | - TZ=${TZ} 27 | ports: 28 | - 8080:80 29 | - 8888:8080 30 | command: > 31 | --providers.docker.watch=true 32 | --api.insecure=true 33 | --api.dashboard=true 34 | --log.level=DEBUG 35 | volumes: 36 | - /var/run/docker.sock:/var/run/docker.sock 37 | 38 | whoami: 39 | image: containous/whoami 40 | container_name: whoami 41 | labels: 42 | - traefik.http.services.whoami.loadbalancer.server.port=80 43 | - traefik.http.routers.whoami.middlewares=auth 44 | - traefik.http.routers.whoami.rule=PathPrefix("/") 45 | environment: 46 | - TZ=${TZ} 47 | -------------------------------------------------------------------------------- /examples/maximal_conf.yml: -------------------------------------------------------------------------------- 1 | sys: 2 | log_level: INFORMATION 3 | port: 5050 4 | 5 | jwt: 6 | # You need a long secret 7 | # Too short and STI will not work 8 | secret: |- 9 | O Canada! 10 | Our home and native land! 11 | True patriot love in all of us command. 12 | 13 | With glowing hearts we see thee rise, 14 | The True North strong and free! 15 | 16 | From far and wide, 17 | O Canada, we stand on guard for thee. 18 | 19 | God keep our land glorious and free! 20 | O Canada, we stand on guard for thee. 21 | 22 | O Canada, we stand on guard for thee. 23 | 24 | auth: 25 | # domains in the whitelist are whitelisted 26 | whitelist: 27 | - novascotia.com 28 | - www.tourismpei.com 29 | 30 | # Set this for your base level domain 31 | # So you can avoid login for each subdomain 32 | base_domains: 33 | - canada.ca 34 | - nu.ca 35 | 36 | groups: 37 | - name: quebec 38 | sub_domains: 39 | - "*" 40 | - name: saskatchewan 41 | sub_domains: 42 | # ALL subdomains for canada.ca 43 | # ie. *.canada.ca 44 | - canada.ca 45 | - www.tourismnewbrunswick.ca 46 | - name: newfoundland 47 | sub_domains: 48 | - www.gov.nu.ca 49 | 50 | users: 51 | # Yukon has access to all the gucci 52 | - name: yukon 53 | password: yukon 54 | # Yukon's login session only lasts 1 day 55 | # Default is 7 days 56 | session: 1.0 57 | groups: 58 | - quebec 59 | # nunavut has access to 60 | # - *.canada.ca 61 | # - www.tourismnewbrunswick.ca 62 | # - www.gov.nu.ca 63 | - name: nunavut 64 | password: nunavut 65 | groups: 66 | - saskatchewan 67 | - newfoundland 68 | 69 | # Kind of like Fail2Ban 70 | # Default is 5 requests / 30s 71 | rate_limit: 72 | # Use Cf-Connecting-Ip if Cloudflare, and so on 73 | # Defaults to X-Forwarded-For (No reverse proxies beyond Traefik) 74 | headers: 75 | - Cf-Connecting-Ip 76 | - Another-Header 77 | - So-on 78 | rate: 5 79 | timer: 30 80 | 81 | display: 82 | # Site title when you login / out 83 | title: Simple Traefik Identity 84 | # Any valid image uri works 85 | background: |- 86 | https://github.com/ms-jpq/simple-traefik-identity/raw/master/src/views/assets/xp.jpg 87 | -------------------------------------------------------------------------------- /examples/minimal_conf.yml: -------------------------------------------------------------------------------- 1 | jwt: 2 | # You need a long secret 3 | # Too short and STI will not work 4 | secret: |- 5 | O Canada! 6 | Our home and native land! 7 | True patriot love in all of us command. 8 | 9 | With glowing hearts we see thee rise, 10 | The True North strong and free! 11 | 12 | From far and wide, 13 | O Canada, we stand on guard for thee. 14 | 15 | God keep our land glorious and free! 16 | O Canada, we stand on guard for thee. 17 | 18 | O Canada, we stand on guard for thee. 19 | 20 | auth: 21 | # Set this for your base level domain 22 | # So you can avoid login for each subdomain 23 | # not required tho 24 | base_domains: 25 | - gc.ca 26 | 27 | groups: 28 | - name: quebec 29 | sub_domains: 30 | - "*" 31 | 32 | users: 33 | # Yukon has access to all the gucci 34 | - name: yukon 35 | password: yukon 36 | groups: 37 | - quebec 38 | -------------------------------------------------------------------------------- /paket.dependencies: -------------------------------------------------------------------------------- 1 | source https://api.nuget.org/v3/index.json 2 | 3 | nuget FSharp.Core 4 | nuget Thoth.Json.Net 5 | nuget YamlDotnet 6 | nuget System.IdentityModel.Tokens.Jwt 7 | 8 | 9 | github ms-jpq/fda:94e2eb51c38a2ca296513759ff82da672d3020d5 10 | github giraffe-fsharp/Giraffe:4c1e553941e382907bdc279257d1b8549ebd79c8 11 | -------------------------------------------------------------------------------- /preview/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ms-jpq/simple-traefik-identity/f012fc5cb9e395ad8f3d89e5eb4c371e9c001533/preview/login.png -------------------------------------------------------------------------------- /preview/logoff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ms-jpq/simple-traefik-identity/f012fc5cb9e395ad8f3d89e5eb4c371e9c001533/preview/logoff.png -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eu 4 | set -o pipefail 5 | 6 | IMAGE="msjpq/simple-traefik-identity:latest" 7 | 8 | cd "$(dirname "$0")" 9 | docker build -t "$IMAGE" . 10 | 11 | if [[ $# -gt 0 ]] 12 | then 13 | docker push "$IMAGE" 14 | fi 15 | -------------------------------------------------------------------------------- /src/consts.fs: -------------------------------------------------------------------------------- 1 | namespace STI 2 | 3 | open System 4 | open System.IO 5 | 6 | module Consts = 7 | 8 | let PROJECTURI = "https://ms-jpq.github.io/simple-traefik-identity/" 9 | 10 | let CONTENTROOT = Directory.GetCurrentDirectory() 11 | 12 | let APPCONF = "STI_CONF" 13 | 14 | let CONFFILE = Path.Combine(CONTENTROOT, "config", "conf.yml") 15 | 16 | let RESOURCESDIR = Path.Combine(CONTENTROOT, "views/") 17 | 18 | let private readme = 19 | sprintf """ 20 | Simple Traefik Identity (STI) 21 | STI is a Single Sign-On service for Traefik 22 | ============================================================================== 23 | - 24 | For basic usage, please reference 25 | https://github.com/ms-jpq/simple-traefik-identity/blob/master/examples/minimal_conf.yml 26 | - 27 | - 28 | For advanced usage, please reference 29 | https://github.com/ms-jpq/simple-traefik-identity/blob/master/examples/maximal_conf.yml 30 | - 31 | ============================================================================== 32 | https://ms-jpq.github.io/simple-traefik-identity/ 33 | """ 34 | 35 | let README = readme 36 | 37 | [] 38 | let WEBSRVPORT = 6060 39 | 40 | let FORWARDHEADER = "X-Forwarded-User" 41 | 42 | let DEFAULTTITLE = "Simple Traefik Identity" 43 | 44 | let BACKGROUND = "/assets/xp.jpg" 45 | 46 | let COOKIENAME = "_sti_jwt" 47 | 48 | let COOKIEMAXAGE = TimeSpan.FromDays(4200.0) 49 | 50 | let TOKENISSUER = "STI" 51 | 52 | let TOKENLIFESPAN = TimeSpan.FromDays(7.0) 53 | 54 | let RATETIMER = TimeSpan.FromSeconds(30.0) 55 | 56 | let RATE = 5 57 | -------------------------------------------------------------------------------- /src/controllers/authenticate.fs: -------------------------------------------------------------------------------- 1 | namespace STI.Controllers 2 | 3 | open STI.Env 4 | open STI.Models.Auth 5 | open STI.Models.RateLimit 6 | open STI.State 7 | open STI.Views 8 | open DomainAgnostic 9 | open DomainAgnostic.Globals 10 | open DotNetExtensions 11 | open DotNetExtensions.Routing 12 | open Microsoft.AspNetCore.Mvc 13 | open Microsoft.AspNetCore.Http 14 | open Microsoft.AspNetCore.Http.Extensions 15 | open Microsoft.Extensions.Logging 16 | open System 17 | 18 | 19 | 20 | [] 21 | type Authenticate(logger: ILogger, deps: Container, state: GlobalVar) = 22 | inherit Controller() 23 | 24 | let cookie = deps.Boxed.cookie 25 | let jwt = deps.Boxed.jwt 26 | let model = deps.Boxed.model 27 | let display = deps.Boxed.display 28 | 29 | let policy (domain: string) = 30 | let policy = CookieOptions() 31 | policy.Path <- "/" 32 | policy.MaxAge <- cookie.maxAge |> Nullable 33 | policy.Domain <- 34 | model.baseDomains 35 | |> Seq.tryFind domain.EndsWith 36 | |> Option.map ((+) ".") 37 | |> Option.Recover domain 38 | 39 | policy 40 | 41 | 42 | [] 43 | [] 44 | member self.Authenticate() = 45 | async { 46 | let req, resp, conn = Exts.Ctx self.HttpContext 47 | resp.StatusCode <- StatusCodes.Status418ImATeapot 48 | 49 | let domain = req.Host |> string 50 | 51 | let token = 52 | Exts.Headers req 53 | |> Map.MapValues string 54 | |> Map.tryFind "Authorization" 55 | |> Option.bind (newToken jwt model) 56 | 57 | let ip = conn.RemoteIpAddress |> string 58 | 59 | let! st = state.Get() 60 | let go, ns = ip |> next deps.Boxed.rateLimit st.history 61 | 62 | let s2 = { history = ns } 63 | do! state.Put(s2) |> Async.Ignore 64 | 65 | match (go, token) with 66 | | (true, Some tkn) -> 67 | req.GetDisplayUrl() 68 | |> sprintf "🦄 -- Authenticated -- 🦄\n%s" 69 | |> logger.LogWarning 70 | resp.Cookies.Append(cookie.name, tkn, policy domain) 71 | return {| ok = true |} |> JsonResult :> ActionResult 72 | | _ -> 73 | let uri = req.GetDisplayUrl() 74 | ns 75 | |> Map.tryFind ip 76 | |> Option.Recover Seq.empty 77 | |> Seq.map (flip (sprintf "⏱ - %A <- %s") ip) 78 | |> fun x -> String.Join("\n", x) 79 | |> sprintf "⛔️ -- Authentication Failure -- ⛔️\n%s\n%s" uri 80 | |> logger.LogWarning 81 | return {| ok = false 82 | timeout = not go |} |> JsonResult :> ActionResult 83 | } 84 | |> Async.StartAsTask 85 | 86 | 87 | [] 88 | [] 89 | member self.Deauthenticate() = 90 | async { 91 | let req, resp, conn = Exts.Ctx self.HttpContext 92 | let domain = req.Host |> string 93 | req.GetDisplayUrl() 94 | |> sprintf "👋 -- Deauthenticated -- 👋\n%s" 95 | |> logger.LogWarning 96 | resp.Cookies.Delete(cookie.name, policy domain) 97 | return StatusCodes.Status418ImATeapot |> StatusCodeResult :> ActionResult 98 | } 99 | |> Async.StartAsTask 100 | 101 | 102 | [] 103 | member self.Login() = 104 | async { 105 | let req, resp, conn = Exts.Ctx self.HttpContext 106 | let domain = req.Host |> string 107 | 108 | let state = 109 | Exts.Cookies req 110 | |> Map.tryFind cookie.name 111 | |> checkAuth jwt model domain 112 | 113 | match state with 114 | | Some(Authorized(user)) -> 115 | assert (false) 116 | [ model.forwardHeader, user ] |> flip Exts.AddHeaders resp 117 | return StatusCodes.Status204NoContent |> StatusCodeResult :> ActionResult 118 | | Some Unauthorized -> 119 | req.GetDisplayUrl() 120 | |> sprintf "🔐 -- Unauthorized -- 🔐\n%s" 121 | |> logger.LogInformation 122 | let html = Logout.Render display 123 | resp.StatusCode <- StatusCodes.Status403Forbidden 124 | return self.Content(html, "text/html") :> ActionResult 125 | | Some Unauthenticated 126 | | _ -> 127 | req.GetDisplayUrl() 128 | |> sprintf "🔑 -- Authenticating -- 🔑\n%s" 129 | |> logger.LogInformation 130 | let html = Login.Render display 131 | resp.StatusCode <- StatusCodes.Status401Unauthorized 132 | return self.Content(html, "text/html") :> ActionResult 133 | } 134 | |> Async.StartAsTask 135 | -------------------------------------------------------------------------------- /src/entry.fs: -------------------------------------------------------------------------------- 1 | namespace STI 2 | 3 | open DomainAgnostic 4 | open Consts 5 | open STI.State 6 | open STI.Env 7 | open DomainAgnostic.Globals 8 | open Microsoft.Extensions.Hosting 9 | 10 | 11 | module Entry = 12 | 13 | [] 14 | let main argv = 15 | echo README 16 | 17 | let deps = Opts() 18 | echo "🙆‍♀️ -- Options -- 🙆‍♀️" 19 | Variables.Desc deps |> echo 20 | 21 | use state = new GlobalVar({ history = Map.empty }) 22 | 23 | use server = Server.Build deps state 24 | server.Run() 25 | 0 26 | -------------------------------------------------------------------------------- /src/env.fs: -------------------------------------------------------------------------------- 1 | namespace STI 2 | 3 | 4 | open DomainAgnostic 5 | open Consts 6 | open Microsoft.Extensions.Logging 7 | open System 8 | open Newtonsoft.Json 9 | open Newtonsoft.Json.Converters 10 | open Thoth.Json.Net 11 | open YamlDotNet.Serialization 12 | open System.Dynamic 13 | open System.Text 14 | 15 | 16 | module Env = 17 | 18 | type SysOpts = 19 | { logLevel: LogLevel 20 | port: int } 21 | 22 | static member Def = 23 | { logLevel = LogLevel.Warning 24 | port = WEBSRVPORT } 25 | 26 | static member Decoder = 27 | let resolve (get: Decode.IGetters) = 28 | let logLevel = 29 | get.Optional.Field "log_level" Decode.string 30 | |> Option.bind Parse.Enum 31 | |> Option.Recover SysOpts.Def.logLevel 32 | 33 | let port = get.Optional.Field "port" Decode.int |> Option.Recover SysOpts.Def.port 34 | 35 | { logLevel = logLevel 36 | port = port } 37 | 38 | Decode.object resolve 39 | 40 | 41 | type CookieOpts = 42 | { name: string 43 | maxAge: TimeSpan } 44 | 45 | static member Def = 46 | { name = COOKIENAME 47 | maxAge = COOKIEMAXAGE } 48 | 49 | static member Decoder = 50 | let resolve (get: Decode.IGetters) = 51 | let name = get.Optional.Field "name" Decode.string |> Option.Recover CookieOpts.Def.name 52 | 53 | let maxAge = 54 | get.Optional.Field "max_age" Decode.string 55 | |> Option.bind Parse.Float 56 | |> Option.map TimeSpan.FromHours 57 | |> Option.Recover CookieOpts.Def.maxAge 58 | { name = name 59 | maxAge = maxAge } 60 | 61 | Decode.object resolve 62 | 63 | 64 | type JWTopts = 65 | { [] 66 | secret: byte array 67 | issuer: string } 68 | 69 | static member Decoder = 70 | let resolve (get: Decode.IGetters) = 71 | let secret = get.Required.Field "secret" Decode.string |> Encoding.UTF8.GetBytes 72 | 73 | match secret.Length with 74 | | l when l <= 150 -> failwith "☢️ -- PICK A LONGER SECRET -- ☢️" 75 | | _ -> () 76 | 77 | { secret = secret 78 | issuer = TOKENISSUER } 79 | 80 | Decode.object resolve 81 | 82 | 83 | type Domains = 84 | | Named of string seq 85 | | All 86 | 87 | type User = 88 | { name: string 89 | [] 90 | password: string 91 | loginSession: TimeSpan 92 | subDomains: Domains } 93 | 94 | type AuthModel = 95 | { forwardHeader: string 96 | baseDomains: string seq 97 | whitelist: string seq 98 | users: User seq } 99 | 100 | static member Decoder = 101 | let pDomain acc curr = 102 | match (acc, curr) with 103 | | _, "*" -> All 104 | | All, _ -> All 105 | | Named a, c -> a ++ [ c ] |> Named 106 | 107 | let resovleG (get: Decode.IGetters) = 108 | let name = get.Required.Field "name" Decode.string 109 | 110 | let subDomains = get.Required.Field "sub_domains" (Decode.list Decode.string) |> Seq.ofList 111 | 112 | name, subDomains 113 | 114 | let resovleU (groups: (string * string seq) seq) (get: Decode.IGetters) = 115 | let name = get.Required.Field "name" Decode.string 116 | let password = get.Required.Field "password" Decode.string 117 | 118 | let session = 119 | get.Optional.Field "session" Decode.string 120 | |> Option.bind Parse.Float 121 | |> Option.map TimeSpan.FromDays 122 | |> Option.Recover TOKENLIFESPAN 123 | 124 | let chk = 125 | get.Required.Field "groups" (Decode.list Decode.string) 126 | |> Set 127 | |> flip Set.contains 128 | 129 | let subDomains = 130 | groups 131 | |> Seq.filter (fst >> chk) 132 | |> Seq.Bind snd 133 | |> Seq.fold pDomain (Named Seq.empty) 134 | 135 | { name = name 136 | password = password 137 | loginSession = session 138 | subDomains = subDomains } 139 | 140 | let resolve (get: Decode.IGetters) = 141 | let baseDomains = 142 | get.Optional.Field "base_domains" (Decode.list Decode.string) 143 | |> Option.Recover [] 144 | |> Seq.ofList 145 | 146 | let whitelist = 147 | get.Optional.Field "whitelist" (Decode.list Decode.string) 148 | |> Option.Recover [] 149 | |> Seq.ofList 150 | 151 | let groups = 152 | get.Required.Field "groups" 153 | (resovleG 154 | |> Decode.object 155 | |> Decode.list) |> Seq.ofList 156 | let users = 157 | get.Required.Field "users" 158 | (groups 159 | |> resovleU 160 | |> Decode.object 161 | |> Decode.list) |> Seq.ofList 162 | { forwardHeader = FORWARDHEADER 163 | baseDomains = baseDomains 164 | whitelist = whitelist 165 | users = users } 166 | 167 | Decode.object resolve 168 | 169 | 170 | type RateLimit = 171 | { headers: string seq 172 | rate: int 173 | timer: TimeSpan } 174 | 175 | static member Def = 176 | { headers = [] 177 | rate = RATE 178 | timer = RATETIMER } 179 | 180 | static member Decoder = 181 | let resolve (get: Decode.IGetters) = 182 | let headers = 183 | get.Optional.Field "header" (Decode.array Decode.string) 184 | |> Option.map Seq.ofArray 185 | |> Option.Recover RateLimit.Def.headers 186 | 187 | let rate = get.Optional.Field "rate" Decode.int |> Option.Recover RateLimit.Def.rate 188 | 189 | let timer = 190 | get.Optional.Field "timer" Decode.string 191 | |> Option.bind Parse.Float 192 | |> Option.map TimeSpan.FromSeconds 193 | |> Option.Recover RateLimit.Def.timer 194 | { headers = headers 195 | rate = rate 196 | timer = timer } 197 | 198 | Decode.object resolve 199 | 200 | 201 | type Display = 202 | { resources: string 203 | title: string 204 | background: string } 205 | 206 | static member Def = 207 | { resources = RESOURCESDIR 208 | title = DEFAULTTITLE 209 | background = BACKGROUND } 210 | 211 | static member Decoder = 212 | let resolve (get: Decode.IGetters) = 213 | let title = get.Optional.Field "title" Decode.string |> Option.Recover Display.Def.title 214 | let background = 215 | get.Optional.Field "background" Decode.string |> Option.Recover Display.Def.background 216 | { resources = Display.Def.resources 217 | title = title 218 | background = background } 219 | 220 | Decode.object resolve 221 | 222 | 223 | type Variables = 224 | { sys: SysOpts 225 | cookie: CookieOpts 226 | jwt: JWTopts 227 | model: AuthModel 228 | rateLimit: RateLimit 229 | display: Display } 230 | 231 | static member Decoder = 232 | let resolve (get: Decode.IGetters) = 233 | 234 | let sys = get.Optional.Field "sys" SysOpts.Decoder |> Option.Recover SysOpts.Def 235 | let cookie = get.Optional.Field "cookie" CookieOpts.Decoder |> Option.Recover CookieOpts.Def 236 | let jwt = get.Required.Field "jwt" JWTopts.Decoder 237 | let model = get.Required.Field "auth" AuthModel.Decoder 238 | let rateLimit = get.Optional.Field "rate_limit" RateLimit.Decoder |> Option.Recover RateLimit.Def 239 | let display = get.Optional.Field "display" Display.Decoder |> Option.Recover Display.Def 240 | 241 | { sys = sys 242 | cookie = cookie 243 | jwt = jwt 244 | model = model 245 | rateLimit = rateLimit 246 | display = display } 247 | Decode.object resolve 248 | 249 | static member Desc(v: Variables) = 250 | let json = JsonConvert.SerializeObject v 251 | let expanded = JsonConvert.DeserializeObject(json, ExpandoObjectConverter()) 252 | Serializer().Serialize(expanded) 253 | 254 | 255 | let Y2J(yaml: string) = 256 | yaml 257 | |> DeserializerBuilder().Build().Deserialize 258 | |> SerializerBuilder().JsonCompatible().Build().Serialize 259 | 260 | 261 | let Opts() = 262 | let conf = 263 | ENV() 264 | |> Map.tryFind APPCONF 265 | |> Option.Recover CONFFILE 266 | conf 267 | |> slurp 268 | |> Async.RunSynchronously 269 | |> Result.bind (Result.New Y2J) 270 | |> Result.mapError 271 | (conf 272 | |> sprintf "☢️ -- Failed to load config %s -- ☢️" 273 | |> constantly) 274 | |> Result.bind (Decode.fromString Variables.Decoder) 275 | |> Result.mapError Exception 276 | |> Result.ForceUnwrap 277 | -------------------------------------------------------------------------------- /src/main.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp3.1 5 | 6 | 7 | 8 | True 9 | paket-files/GiraffeViewEngine.fs 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/middlewares/auth.fs: -------------------------------------------------------------------------------- 1 | namespace STI.Middlewares 2 | 3 | open STI.Env 4 | open STI.Models.Auth 5 | open DomainAgnostic 6 | open DotNetExtensions 7 | open Microsoft.AspNetCore.Http 8 | open Microsoft.AspNetCore.Http.Extensions 9 | open Microsoft.Extensions.Logging 10 | 11 | 12 | module Auth = 13 | 14 | type AuthMiddleware(next: RequestDelegate, logger: ILogger, deps: Variables Container) = 15 | 16 | let cookie = deps.Boxed.cookie 17 | let jwt = deps.Boxed.jwt 18 | let model = deps.Boxed.model 19 | 20 | member __.InvokeAsync(ctx: HttpContext) = 21 | let task = 22 | async { 23 | let req, resp, conn = Exts.Ctx ctx 24 | let domain = req.Host |> string 25 | 26 | let state = 27 | Exts.Cookies req 28 | |> Map.tryFind cookie.name 29 | |> checkAuth jwt model domain 30 | 31 | match state with 32 | | Some Whitelisted -> 33 | req.GetDisplayUrl() 34 | |> sprintf "✅ -- Whitelisted -- ✅\n%s" 35 | |> logger.LogInformation 36 | resp.StatusCode <- StatusCodes.Status204NoContent 37 | | Some(Authorized(user)) -> 38 | req.GetDisplayUrl() 39 | |> sprintf "✅ -- Authorized: %s -- ✅\n%s" user 40 | |> logger.LogInformation 41 | [ model.forwardHeader, user ] |> flip Exts.AddHeaders resp 42 | resp.StatusCode <- StatusCodes.Status204NoContent 43 | | _ -> do! next.Invoke(ctx) |> Async.AwaitTask 44 | } 45 | task |> Async.StartAsTask 46 | -------------------------------------------------------------------------------- /src/middlewares/rewrite.fs: -------------------------------------------------------------------------------- 1 | namespace STI.Middlewares 2 | 3 | open STI.Env 4 | open DomainAgnostic 5 | open DotNetExtensions 6 | open Microsoft.AspNetCore.Http 7 | open System.Net 8 | 9 | module Rewrite = 10 | 11 | type RewriteMiddleware(next: RequestDelegate, deps: Variables Container) = 12 | 13 | let ipHeaders = deps.Boxed.rateLimit.headers 14 | 15 | member __.InvokeAsync(ctx: HttpContext) = 16 | let task = 17 | async { 18 | let req, resp, conn = Exts.Ctx ctx 19 | let find = Exts.Headers req |> flip Map.tryFind 20 | 21 | conn.RemoteIpAddress <- 22 | ipHeaders 23 | |> Seq.tryPick find 24 | |> Option.orElse (find "X-Forwarded-For") 25 | |> Option.bind 26 | (string 27 | >> (Result.New IPAddress.Parse) 28 | >> Option.OfResult) 29 | |> Option.Recover conn.RemoteIpAddress 30 | 31 | conn.RemotePort <- 32 | find "X-Forwarded-Port" 33 | |> Option.bind (string >> Parse.Int) 34 | |> Option.Recover conn.RemotePort 35 | 36 | req.Scheme <- 37 | find "X-Forwarded-Proto" 38 | |> Option.map string 39 | |> Option.Recover req.Scheme 40 | 41 | req.Method <- 42 | find "X-Forwarded-Method" 43 | |> Option.map string 44 | |> Option.Recover req.Method 45 | 46 | req.Host <- 47 | find "X-Forwarded-Host" 48 | |> Option.bind 49 | (string 50 | >> (Result.New HostString) 51 | >> Option.OfResult) 52 | |> Option.Recover req.Host 53 | 54 | req.Path <- 55 | find "X-Forwarded-Uri" 56 | |> Option.bind 57 | (string 58 | >> (Result.New PathString) 59 | >> Option.OfResult) 60 | |> Option.Recover req.Path 61 | 62 | 63 | do! next.Invoke(ctx) |> Async.AwaitTask 64 | } 65 | task |> Async.StartAsTask 66 | -------------------------------------------------------------------------------- /src/models/auth.fs: -------------------------------------------------------------------------------- 1 | namespace STI.Models 2 | 3 | open STI.Env 4 | open DomainAgnostic 5 | open DomainAgnostic.Encode 6 | open JWT 7 | open Newtonsoft.Json 8 | 9 | 10 | module Auth = 11 | 12 | type AccessClaims = 13 | { user: string 14 | access: Domains } 15 | 16 | static member Serialize(claim: AccessClaims) = claim |> JsonConvert.SerializeObject 17 | 18 | static member DeSerialize claim = 19 | claim 20 | |> Result.New JsonConvert.DeserializeObject 21 | |> Option.OfResult 22 | 23 | type AuthState = 24 | | Unauthenticated 25 | | Unauthorized 26 | | Whitelisted 27 | | Authorized of string 28 | 29 | let private decode (header: string) = 30 | try 31 | let credentials = header.Split(" ") 32 | if credentials.[0] <> "Basic" then failwith "<> Basic" 33 | 34 | let decoded = 35 | credentials.[1] 36 | |> base64decode 37 | |> fun s -> s.Split(":") 38 | 39 | let username = decoded.[0] 40 | let password = decoded.[1] 41 | (username, password) |> Some 42 | 43 | with _ -> None 44 | 45 | 46 | let private login (model: AuthModel) username password = 47 | let seek (u: User) = u.name = username && u.password = password 48 | model.users |> Seq.tryFind seek 49 | 50 | let private tokenize opts (user: User) = 51 | let access = 52 | { user = user.name 53 | access = user.subDomains } 54 | AccessClaims.Serialize access |> newJWT opts user.loginSession 55 | 56 | 57 | let newToken opts model header = 58 | header 59 | |> decode 60 | |> Option.bind ((<||) (login model)) 61 | |> Option.map (tokenize opts) 62 | 63 | 64 | let checkAuth opts model (domain: string) cookie = 65 | let contain = model.whitelist |> Seq.Contains domain.EndsWith 66 | 67 | let state = 68 | maybe { 69 | let! claims = cookie |> Option.bind (readJWT opts) 70 | let! acc = AccessClaims.DeSerialize claims 71 | let auth = 72 | match acc.access with 73 | | All -> Authorized acc.user 74 | | Named domains -> 75 | let contains = domains |> Seq.Contains domain.EndsWith 76 | match contains with 77 | | true -> Authorized acc.user 78 | | false -> Unauthorized 79 | return auth 80 | } 81 | match contain with 82 | | true -> Some Whitelisted 83 | | false -> state 84 | -------------------------------------------------------------------------------- /src/models/jwt.fs: -------------------------------------------------------------------------------- 1 | namespace STI.Models 2 | 3 | open STI.Env 4 | open DomainAgnostic 5 | open Microsoft.IdentityModel.Tokens 6 | open System 7 | open System.IdentityModel.Tokens.Jwt 8 | 9 | 10 | module JWT = 11 | 12 | let readJWT (opts: JWTopts) (token: string) = 13 | let jwt = JwtSecurityTokenHandler() 14 | 15 | let validation = 16 | let desc = TokenValidationParameters() 17 | desc.IssuerSigningKey <- opts.secret |> SymmetricSecurityKey 18 | desc.ValidIssuer <- opts.issuer 19 | desc.ValidateAudience <- false 20 | desc 21 | try 22 | jwt.ValidateToken(token, validation, ref null) |> ignore 23 | jwt.ReadJwtToken(token).Payload 24 | |> Map.OfKVP 25 | |> Map.tryFind "payload" 26 | |> Option.bind CAST 27 | with _ -> None 28 | 29 | 30 | let newJWT (opts: JWTopts) lifespan (payload: string) = 31 | let jwt = JwtSecurityTokenHandler() 32 | let now = DateTime.UtcNow 33 | let key = opts.secret |> SymmetricSecurityKey 34 | let algo = SecurityAlgorithms.HmacSha256Signature 35 | 36 | let desc = 37 | let desc = SecurityTokenDescriptor() 38 | desc.SigningCredentials <- SigningCredentials(key, algo) 39 | desc.Issuer <- opts.issuer 40 | desc.IssuedAt <- now |> Nullable 41 | desc.Expires <- now + lifespan |> Nullable 42 | desc 43 | 44 | let token = jwt.CreateJwtSecurityToken(desc) 45 | token.Payload.Add("payload", payload) 46 | jwt.WriteToken token 47 | -------------------------------------------------------------------------------- /src/models/ratelimit.fs: -------------------------------------------------------------------------------- 1 | namespace STI.Models 2 | 3 | open DomainAgnostic 4 | open STI.Env 5 | open System 6 | 7 | 8 | module RateLimit = 9 | 10 | let next limit history ip = 11 | let now = DateTime.UtcNow 12 | let ago = now - limit.timer 13 | 14 | let hist = 15 | history 16 | |> Map.tryFind ip 17 | |> Option.Recover Seq.empty 18 | 19 | let go = 20 | hist 21 | |> Seq.Count((<) ago) 22 | |> (>) limit.rate 23 | 24 | let next = 25 | hist 26 | |> Seq.Appending now 27 | |> Seq.filter ((<) ago) 28 | |> flip (Map.add ip) history 29 | 30 | go, next 31 | -------------------------------------------------------------------------------- /src/pages/layout.fs: -------------------------------------------------------------------------------- 1 | namespace STI.Views 2 | 3 | open DomainAgnostic 4 | open Giraffe.GiraffeViewEngine 5 | open STI.Consts 6 | 7 | 8 | module Layout = 9 | 10 | let _css = sprintf "body { --background-image: url(%s); }" 11 | 12 | let loadContent file = 13 | sprintf "%s/%s" RESOURCESDIR file 14 | |> slurp 15 | |> Async.RunSynchronously 16 | |> Result.ForceUnwrap 17 | 18 | let loadJS js = 19 | js 20 | |> Seq.map (loadContent >> (fun c -> script [] [ rawText c ])) 21 | |> Seq.toList 22 | 23 | let loadCSS css = 24 | css 25 | |> Seq.map (loadContent >> (fun c -> style [] [ rawText c ])) 26 | |> Seq.toList 27 | 28 | let Layout js css background tit form = 29 | let headElem = 30 | [ meta [ _charset "utf-8" ] 31 | meta 32 | [ _name "viewport" 33 | _content "width=device-width, initial-scale=1" ] 34 | title [] [ str tit ] 35 | style [] 36 | [ background 37 | |> _css 38 | |> str ] ] 39 | html [] 40 | [ head [] (headElem @ loadJS js @ loadCSS css) 41 | body [] 42 | [ div [] [] 43 | main [] 44 | [ div [] 45 | [ div [] [] 46 | span [] [] 47 | div [] form 48 | span [] [] 49 | div [] [] ] ] 50 | div [] [] 51 | footer [] [ a [ _href PROJECTURI ] [ str "★ github ★" ] ] 52 | div [] [] ] ] 53 | -------------------------------------------------------------------------------- /src/pages/login.fs: -------------------------------------------------------------------------------- 1 | namespace STI.Views 2 | 3 | open STI.Env 4 | open DomainAgnostic 5 | open Giraffe.GiraffeViewEngine 6 | open Layout 7 | 8 | 9 | module Login = 10 | 11 | let private login = 12 | [ div [] [] 13 | form [ _action "" ] 14 | [ div [] 15 | [ span [] [] 16 | figure [] 17 | [ div [] [] 18 | div [] [] ] 19 | section [] 20 | [ div [] 21 | [ span [] [] 22 | input 23 | [ _type "text" 24 | _name "username" ] ] 25 | div [] 26 | [ span [] [] 27 | input 28 | [ _type "password" 29 | _name "password" ] ] 30 | div [] 31 | [ span [] [] 32 | input 33 | [ _type "submit" 34 | _value " " ] ] ] 35 | span [] [] ] ] 36 | div [] [] ] 37 | 38 | 39 | let Render(display: Display) = 40 | let js = [ "/js/login.js" ] 41 | let css = [ "/css/base.css"; "/css/login.css" ] 42 | let nodes = Layout js css display.background display.title login 43 | nodes |> renderHtmlDocument 44 | -------------------------------------------------------------------------------- /src/pages/logout.fs: -------------------------------------------------------------------------------- 1 | namespace STI.Views 2 | 3 | open STI.Env 4 | open DomainAgnostic 5 | open Giraffe.GiraffeViewEngine 6 | open Layout 7 | 8 | 9 | module Logout = 10 | 11 | let private logout = 12 | form [ _action "" ] 13 | [ input 14 | [ _type "submit" 15 | _value " " ] ] 16 | 17 | let Render(display: Display) = 18 | let js = [ "/js/logout.js" ] 19 | let css = [ "/css/base.css"; "/css/logout.css" ] 20 | let nodes = Layout js css display.background display.title [ logout ] 21 | nodes |> renderHtmlDocument 22 | -------------------------------------------------------------------------------- /src/paket.references: -------------------------------------------------------------------------------- 1 | FSharp.Core 2 | Thoth.Json.Net 3 | YamlDotnet 4 | System.IdentityModel.Tokens.Jwt 5 | 6 | File: src/Giraffe/GiraffeViewEngine.fs giraffe-fsharp/Giraffe 7 | -------------------------------------------------------------------------------- /src/server.fs: -------------------------------------------------------------------------------- 1 | namespace STI 2 | 3 | open DomainAgnostic 4 | open DomainAgnostic.Globals 5 | open Microsoft.AspNetCore.CookiePolicy 6 | open Microsoft.AspNetCore.Http 7 | open Microsoft.AspNetCore.Builder 8 | open Microsoft.AspNetCore.Hosting 9 | open Microsoft.Extensions.DependencyInjection 10 | open Microsoft.Extensions.Logging 11 | open Microsoft.Extensions.Hosting 12 | open System 13 | open STI.Env 14 | open STI.Consts 15 | open STI.Middlewares.Rewrite 16 | open STI.Middlewares.Auth 17 | 18 | 19 | 20 | [] 21 | module Server = 22 | 23 | let private confLogging level (logging: ILoggingBuilder) = 24 | logging.AddFilter((<=) level) |> ignore 25 | logging.AddConsole() |> ignore 26 | 27 | 28 | let private confServices deps (globals: GlobalVar<'D>) (services: IServiceCollection) = 29 | services.AddSingleton(Container deps) |> ignore 30 | services.AddSingleton(globals) |> ignore 31 | services.AddControllers() |> ignore 32 | 33 | 34 | let private confStaticFile = 35 | let options = StaticFileOptions() 36 | options.OnPrepareResponse <- fun ctx -> ctx.Context.Response.StatusCode <- StatusCodes.Status418ImATeapot 37 | options 38 | 39 | 40 | let private confCookies = 41 | let options = CookiePolicyOptions() 42 | options.HttpOnly <- HttpOnlyPolicy.Always 43 | options.Secure <- CookieSecurePolicy.SameAsRequest 44 | options.MinimumSameSitePolicy <- SameSiteMode.Lax 45 | options 46 | 47 | 48 | let private confApp deps (app: IApplicationBuilder) = 49 | app.UseStatusCodePages() |> ignore 50 | app.UseDeveloperExceptionPage() |> ignore 51 | app.UseMiddleware() |> ignore 52 | app.UseMiddleware() |> ignore 53 | app.UseStaticFiles(confStaticFile) |> ignore 54 | app.UseCookiePolicy(confCookies) |> ignore 55 | app.UseRouting() |> ignore 56 | app.UseCors() |> ignore 57 | app.UseEndpoints(fun ep -> ep.MapControllers() |> ignore) |> ignore 58 | 59 | 60 | let private confWebhost (deps: Variables) gloabls (webhost: IWebHostBuilder) = 61 | webhost.UseWebRoot(RESOURCESDIR) |> ignore 62 | webhost.UseKestrel() |> ignore 63 | webhost.UseUrls(sprintf "http://0.0.0.0:%d" deps.sys.port) |> ignore 64 | webhost.ConfigureServices(confServices deps gloabls) |> ignore 65 | webhost.Configure(Action(confApp deps)) |> ignore 66 | 67 | 68 | let Build<'D> (deps: Variables) (globals: GlobalVar<'D>) = 69 | let host = Host.CreateDefaultBuilder() 70 | host.UseContentRoot(CONTENTROOT) |> ignore 71 | host.ConfigureLogging(confLogging deps.sys.logLevel) |> ignore 72 | host.ConfigureWebHostDefaults(Action(confWebhost deps globals)) |> ignore 73 | host.Build() 74 | -------------------------------------------------------------------------------- /src/state.fs: -------------------------------------------------------------------------------- 1 | namespace STI 2 | 3 | 4 | open DomainAgnostic 5 | open System 6 | 7 | 8 | module State = 9 | type State = 10 | { history: Map } 11 | -------------------------------------------------------------------------------- /src/views/assets/chess.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ms-jpq/simple-traefik-identity/f012fc5cb9e395ad8f3d89e5eb4c371e9c001533/src/views/assets/chess.bmp -------------------------------------------------------------------------------- /src/views/assets/go.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ms-jpq/simple-traefik-identity/f012fc5cb9e395ad8f3d89e5eb4c371e9c001533/src/views/assets/go.png -------------------------------------------------------------------------------- /src/views/assets/key.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ms-jpq/simple-traefik-identity/f012fc5cb9e395ad8f3d89e5eb4c371e9c001533/src/views/assets/key.png -------------------------------------------------------------------------------- /src/views/assets/shutdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ms-jpq/simple-traefik-identity/f012fc5cb9e395ad8f3d89e5eb4c371e9c001533/src/views/assets/shutdown.png -------------------------------------------------------------------------------- /src/views/assets/users.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ms-jpq/simple-traefik-identity/f012fc5cb9e395ad8f3d89e5eb4c371e9c001533/src/views/assets/users.png -------------------------------------------------------------------------------- /src/views/assets/xp.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ms-jpq/simple-traefik-identity/f012fc5cb9e395ad8f3d89e5eb4c371e9c001533/src/views/assets/xp.jpg -------------------------------------------------------------------------------- /src/views/css/base.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 0; 3 | margin: 0; 4 | background-image: var(--background-image); 5 | min-height: 100vh; 6 | max-height: 100%; 7 | background-repeat: no-repeat; 8 | background-size: cover; 9 | background-color: aliceblue; 10 | display: flex; 11 | flex-direction: column; 12 | } 13 | 14 | body > div { 15 | flex-grow: 1; 16 | } 17 | 18 | a { 19 | color: whitesmoke; 20 | } 21 | 22 | input { 23 | border-radius: 0.4em; 24 | } 25 | 26 | main { 27 | margin-left: auto; 28 | margin-right: auto; 29 | display: flex; 30 | flex-direction: row; 31 | flex-wrap: wrap; 32 | justify-content: center; 33 | } 34 | 35 | main > div { 36 | height: 20em; 37 | border-radius: 0.4em; 38 | box-shadow: 0.7em -0.1em 1.0em 0em rgb(40, 40, 40, 0.5); 39 | display: flex; 40 | flex-direction: column; 41 | justify-content: center; 42 | align-items: center; 43 | overflow: hidden; 44 | } 45 | 46 | main > div > div:first-child, 47 | main > div > div:last-child { 48 | width: 100%; 49 | flex-grow: 1; 50 | background: linear-gradient(75deg, rgb(0, 77, 163) 0%, rgb(20, 90, 200) 30%, rgb(0, 77, 163) 100%); 51 | } 52 | 53 | main > div > div:nth-child(3) { 54 | width: 100%; 55 | background: linear-gradient(80deg, rgb(80, 115, 215) 0%, rgb(100, 130, 220) 25%, rgb(80, 115, 215) 100%); 56 | } 57 | 58 | 59 | main > div > span{ 60 | width: 100%; 61 | height: 0.15em; 62 | background: linear-gradient(90deg, lightgrey 0%, orange 50%, lightgrey 100%); 63 | } 64 | 65 | main > div > div:nth-child(3) { 66 | height: 60%; 67 | display: flex; 68 | flex-direction: row; 69 | align-items: center; 70 | justify-content: space-around; 71 | } 72 | 73 | output { 74 | display: none; 75 | } 76 | 77 | footer { 78 | display: flex; 79 | flex-direction: column; 80 | } 81 | 82 | 83 | body > div:last-child { 84 | height: 3em; 85 | width: 100%; 86 | flex-grow: 0; 87 | } 88 | 89 | footer > * { 90 | align-self: center; 91 | margin-bottom: 0.3em; 92 | } 93 | 94 | input[type="submit"] { 95 | cursor: pointer; 96 | } 97 | 98 | @media (max-width: 25em) { 99 | main > div { 100 | width: 100vw; 101 | } 102 | } 103 | 104 | @media (min-width: 25em) { 105 | main > div { 106 | width: 25em; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/views/css/login.css: -------------------------------------------------------------------------------- 1 | form { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: space-between; 5 | align-items: center; 6 | width: 100%; 7 | } 8 | 9 | form > div { 10 | margin-top: 0.5em; 11 | margin-bottom: 0.5em; 12 | width: 100%; 13 | display: flex; 14 | flex-direction: row; 15 | justify-content: space-around; 16 | font-size: larger; 17 | font-weight: bolder; 18 | } 19 | 20 | figure { 21 | display: flex; 22 | flex-direction: row; 23 | justify-content: space-between; 24 | } 25 | 26 | figure > div:last-child { 27 | background-image: url("/assets/chess.bmp"); 28 | background-size: cover; 29 | width: 4em; 30 | height: 4em; 31 | overflow: hidden; 32 | border: solid; 33 | border-radius: 0.25em; 34 | border-color: gold; 35 | border-width: 0.2em; 36 | box-shadow: 0.3em -0.1em 0.3em 0em rgb(40, 40, 40, 0.5); 37 | } 38 | 39 | section { 40 | display: flex; 41 | flex-direction: column; 42 | justify-content: space-evenly; 43 | } 44 | 45 | section > div { 46 | display: flex; 47 | flex-direction: row; 48 | justify-content: space-around; 49 | height: 1.5em; 50 | padding-top: 0.5em; 51 | } 52 | 53 | section > div > span { 54 | flex-grow: 1; 55 | } 56 | 57 | section > div:first-child > span { 58 | background-image: url("/assets/users.png"); 59 | background-size: 100% 100%; 60 | height: 100%; 61 | width: 1.5em; 62 | } 63 | 64 | section > div:nth-child(2) > span { 65 | background-image: url("/assets/key.png"); 66 | background-size: 100% 100%; 67 | height: 100%; 68 | width: 1.5em; 69 | } 70 | 71 | 72 | 73 | section input[type="submit"] { 74 | background: transparent; 75 | background-image: url("/assets/go.png"); 76 | background-size: 100% 100%; 77 | width: 2.5em; 78 | border: none; 79 | } 80 | -------------------------------------------------------------------------------- /src/views/css/logout.css: -------------------------------------------------------------------------------- 1 | form { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: space-between; 5 | align-items: center; 6 | width: 100%; 7 | } 8 | 9 | form > input { 10 | background: transparent; 11 | background-image: url("/assets/shutdown.png"); 12 | background-size: 100% 100%; 13 | width: 6em; 14 | height: 6em; 15 | overflow: hidden; 16 | border: none; 17 | /* box-shadow: 0.3em -0.1em 0.3em 0em rgb(40, 40, 40, 0.5); */ 18 | } 19 | -------------------------------------------------------------------------------- /src/views/js/login.js: -------------------------------------------------------------------------------- 1 | document.addEventListener("DOMContentLoaded", () => { 2 | 3 | const query = async (username, password) => { 4 | try { 5 | const params = { 6 | method: "POST", 7 | headers: { 8 | Authorization: `Basic ${btoa(`${username}:${password}`)}`, 9 | } 10 | } 11 | return (await fetch("/", params)).json() 12 | } catch (e) { 13 | alert(e.message) 14 | } 15 | } 16 | 17 | const submit = async (e) => { 18 | e.preventDefault() 19 | const { currentTarget: ct } = e 20 | const username = ct.querySelector(`input[name="username"]`).value 21 | const password = ct.querySelector(`input[name="password"]`).value 22 | const { ok, timeout } = await query(username, password) 23 | if (!ok) { 24 | const msg = timeout ? `⏳⌛️⏳` : `🙅‍♀️🔐` 25 | alert(msg) 26 | } else { 27 | location.reload() 28 | } 29 | } 30 | 31 | const form = document.querySelector(`form`) 32 | form.onsubmit = submit 33 | form.querySelector(`input[name="username"]`).focus() 34 | 35 | console.log("🙆‍♀️ -- Form Ready -- 🙆‍♀️") 36 | }) 37 | -------------------------------------------------------------------------------- /src/views/js/logout.js: -------------------------------------------------------------------------------- 1 | document.addEventListener("DOMContentLoaded", () => { 2 | 3 | const query = async () => { 4 | try { 5 | const params = { 6 | method: "POST", 7 | headers: { 8 | ["STI-Deauthorization"]: `-`, 9 | } 10 | } 11 | return (await fetch("/", params)).text() 12 | } catch (e) { 13 | alert(e.message) 14 | } 15 | } 16 | 17 | const submit = async (e) => { 18 | e.preventDefault() 19 | await query() 20 | location.reload() 21 | } 22 | 23 | const form = document.querySelector(`form`) 24 | form.onsubmit = submit 25 | 26 | console.log("🙆‍♀️ -- Form Ready -- 🙆‍♀️") 27 | }) 28 | -------------------------------------------------------------------------------- /tests/entry.fs: -------------------------------------------------------------------------------- 1 | namespace Tests 2 | 3 | 4 | 5 | module Entry = 6 | 7 | [] 8 | let main argv = 9 | 10 | 11 | 0 12 | -------------------------------------------------------------------------------- /tests/paket.references: -------------------------------------------------------------------------------- 1 | FSharp.Core 2 | -------------------------------------------------------------------------------- /tests/tests.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp3.1 5 | 6 | 7 | 8 | --------------------------------------------------------------------------------