mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2026-05-17 17:50:14 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8a5f94eb6 |
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create a report to help improve Tinyauth
|
||||||
|
title: "[BUG]"
|
||||||
|
labels: bug
|
||||||
|
assignees:
|
||||||
|
- steveiliop56
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
**To Reproduce**
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
|
||||||
|
**Expected behavior**
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
|
**Screenshots**
|
||||||
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
|
**Logs**
|
||||||
|
Please include the Tinyauth logs below, make sure to not include sensitive info.
|
||||||
|
|
||||||
|
**Device (please complete the following information):**
|
||||||
|
- OS: [e.g. iOS]
|
||||||
|
- Browser [e.g. chrome, safari]
|
||||||
|
- Tinyauth [e.g. v2.1.1]
|
||||||
|
- Docker [e.g. 27.3.1]
|
||||||
|
|
||||||
|
**
|
||||||
|
**Additional context**
|
||||||
|
Add any other context about the problem here.
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
name: Bug Report
|
|
||||||
description: Create a report to help us improve this project
|
|
||||||
title: "[BUG]"
|
|
||||||
labels: bug
|
|
||||||
assignees:
|
|
||||||
- steveiliop56
|
|
||||||
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
Thanks for reporting a bug! Please provide detailed information below.
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: description
|
|
||||||
attributes:
|
|
||||||
label: Describe the Bug
|
|
||||||
description: "A clear and concise description of what the bug is."
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: reproduce
|
|
||||||
attributes:
|
|
||||||
label: How to Reproduce
|
|
||||||
description: Steps to reproduce the behavior.
|
|
||||||
value: |
|
|
||||||
1. Go to '...'
|
|
||||||
2. Click on '....'
|
|
||||||
3. Scroll down to '....'
|
|
||||||
4. See error
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: expected
|
|
||||||
attributes:
|
|
||||||
label: Expected Behavior
|
|
||||||
description: "A clear and concise description of what you expected to happen."
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: context
|
|
||||||
attributes:
|
|
||||||
label: "Additional Context"
|
|
||||||
description: "If applicable add screenshots to help explain your problem."
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: logs
|
|
||||||
attributes:
|
|
||||||
label: "Logs"
|
|
||||||
description: "Please include the Tinyauth logs, make sure to not include sensitive info."
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- type: input
|
|
||||||
id: os
|
|
||||||
attributes:
|
|
||||||
label: Operating System
|
|
||||||
placeholder: "e.g. iOS, Android, Windows, Linux, etc"
|
|
||||||
|
|
||||||
- type: input
|
|
||||||
id: browser
|
|
||||||
attributes:
|
|
||||||
label: Browser
|
|
||||||
placeholder: "e.g. Chrome, Firefox, Safari, Edge, etc"
|
|
||||||
|
|
||||||
- type: input
|
|
||||||
id: tinyauth
|
|
||||||
attributes:
|
|
||||||
label: Tinyauth Version
|
|
||||||
placeholder: "e.g. v5.0.0"
|
|
||||||
|
|
||||||
- type: input
|
|
||||||
id: docker
|
|
||||||
attributes:
|
|
||||||
label: Docker Version (if applicable)
|
|
||||||
placeholder: "e.g. 27.3.1"
|
|
||||||
|
|
||||||
- type: checkboxes
|
|
||||||
id: not-llm
|
|
||||||
attributes:
|
|
||||||
label: Human Written Confirmation
|
|
||||||
options:
|
|
||||||
- label: I confirm this issue was written by me and not generated by an LLM or AI assistant.
|
|
||||||
required: true
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
blank_issues_enabled: true
|
|
||||||
contact_links:
|
|
||||||
- name: Tinyauth Community Support on Discord
|
|
||||||
url: https://discord.gg/eHzVaCzRRd
|
|
||||||
about: Please ask and answer questions here.
|
|
||||||
- name: Tinyauth Documentation
|
|
||||||
url: https://tinyauth.app/docs/getting-started/
|
|
||||||
about: Please check the documentation here.
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Suggest an idea for this project
|
||||||
|
title: "[FEATURE]"
|
||||||
|
labels: enhancement
|
||||||
|
assignees:
|
||||||
|
- steveiliop56
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Is your feature request related to a problem? Please describe.**
|
||||||
|
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||||
|
|
||||||
|
**Describe the solution you'd like**
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
|
**Describe alternatives you've considered**
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context or screenshots about the feature request here.
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
name: Feature request
|
|
||||||
description: Suggest an idea for this project
|
|
||||||
title: "[FEATURE]"
|
|
||||||
labels: enhancement
|
|
||||||
assignees:
|
|
||||||
- steveiliop56
|
|
||||||
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
Thanks for suggesting a feature! Please provide detailed information below.
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: problem
|
|
||||||
attributes:
|
|
||||||
label: Is your feature request related to a problem? Please describe.
|
|
||||||
description: "A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]"
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: solution
|
|
||||||
attributes:
|
|
||||||
label: Describe the solution you'd like.
|
|
||||||
description: "A clear and concise description of what you want to happen."
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: alternatives
|
|
||||||
attributes:
|
|
||||||
label: Describe alternatives you've considered.
|
|
||||||
description: "A clear and concise description of any alternative solutions or features you've considered."
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: context
|
|
||||||
attributes:
|
|
||||||
label: Additional context
|
|
||||||
description: "Add any other context or screenshots about the feature request here."
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- type: checkboxes
|
|
||||||
id: not-llm
|
|
||||||
attributes:
|
|
||||||
label: Human Written Confirmation
|
|
||||||
options:
|
|
||||||
- label: I confirm this request was written by me and not generated by an LLM or AI assistant.
|
|
||||||
required: true
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
version: 2
|
version: 2
|
||||||
updates:
|
updates:
|
||||||
- package-ecosystem: "npm"
|
- package-ecosystem: "bun"
|
||||||
directory: "/frontend"
|
directory: "/frontend"
|
||||||
groups:
|
groups:
|
||||||
minor-patch:
|
minor-patch:
|
||||||
|
|||||||
+15
-12
@@ -15,10 +15,8 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup bun
|
||||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||||
with:
|
|
||||||
package_json_file: ./frontend/package.json
|
|
||||||
|
|
||||||
- name: Setup go
|
- name: Setup go
|
||||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||||
@@ -29,22 +27,27 @@ jobs:
|
|||||||
run: go mod download
|
run: go mod download
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
working-directory: ./frontend
|
run: |
|
||||||
run: pnpm ci
|
cd frontend
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
|
||||||
- name: Set version
|
- name: Set version
|
||||||
run: echo testing > internal/assets/version
|
run: |
|
||||||
|
echo testing > internal/assets/version
|
||||||
|
|
||||||
- name: Lint frontend
|
- name: Lint frontend
|
||||||
working-directory: ./frontend
|
run: |
|
||||||
run: pnpm run lint
|
cd frontend
|
||||||
|
bun run lint
|
||||||
|
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
working-directory: ./frontend
|
run: |
|
||||||
run: pnpm run build
|
cd frontend
|
||||||
|
bun run build
|
||||||
|
|
||||||
- name: Copy frontend
|
- name: Copy frontend
|
||||||
run: cp -r frontend/dist internal/assets/dist
|
run: |
|
||||||
|
cp -r frontend/dist internal/assets/dist
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: go test -coverprofile=coverage.txt -v ./...
|
run: go test -coverprofile=coverage.txt -v ./...
|
||||||
|
|||||||
@@ -59,10 +59,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
ref: nightly
|
ref: nightly
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Install bun
|
||||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||||
with:
|
|
||||||
package_json_file: ./frontend/package.json
|
|
||||||
|
|
||||||
- name: Install go
|
- name: Install go
|
||||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||||
@@ -70,15 +68,18 @@ jobs:
|
|||||||
go-version: "^1.26.0"
|
go-version: "^1.26.0"
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
working-directory: ./frontend
|
run: |
|
||||||
run: pnpm ci
|
cd frontend
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
|
||||||
- name: Install backend dependencies
|
- name: Install backend dependencies
|
||||||
run: go mod download
|
run: |
|
||||||
|
go mod download
|
||||||
|
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
working-directory: ./frontend
|
run: |
|
||||||
run: pnpm run build
|
cd frontend
|
||||||
|
bun run build
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
@@ -104,10 +105,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
ref: nightly
|
ref: nightly
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Install bun
|
||||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||||
with:
|
|
||||||
package_json_file: ./frontend/package.json
|
|
||||||
|
|
||||||
- name: Install go
|
- name: Install go
|
||||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||||
@@ -115,15 +114,18 @@ jobs:
|
|||||||
go-version: "^1.26.0"
|
go-version: "^1.26.0"
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
working-directory: ./frontend
|
run: |
|
||||||
run: pnpm ci
|
cd frontend
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
|
||||||
- name: Install backend dependencies
|
- name: Install backend dependencies
|
||||||
run: go mod download
|
run: |
|
||||||
|
go mod download
|
||||||
|
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
working-directory: ./frontend
|
run: |
|
||||||
run: pnpm run build
|
cd frontend
|
||||||
|
bun run build
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -35,10 +35,8 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Install bun
|
||||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||||
with:
|
|
||||||
package_json_file: ./frontend/package.json
|
|
||||||
|
|
||||||
- name: Install go
|
- name: Install go
|
||||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||||
@@ -46,15 +44,18 @@ jobs:
|
|||||||
go-version: "^1.26.0"
|
go-version: "^1.26.0"
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
working-directory: ./frontend
|
run: |
|
||||||
run: pnpm ci
|
cd frontend
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
|
||||||
- name: Install backend dependencies
|
- name: Install backend dependencies
|
||||||
run: go mod download
|
run: |
|
||||||
|
go mod download
|
||||||
|
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
working-directory: ./frontend
|
run: |
|
||||||
run: pnpm run build
|
cd frontend
|
||||||
|
bun run build
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
@@ -77,10 +78,8 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Install bun
|
||||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||||
with:
|
|
||||||
package_json_file: ./frontend/package.json
|
|
||||||
|
|
||||||
- name: Install go
|
- name: Install go
|
||||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||||
@@ -88,15 +87,18 @@ jobs:
|
|||||||
go-version: "^1.26.0"
|
go-version: "^1.26.0"
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
working-directory: ./frontend
|
run: |
|
||||||
run: pnpm ci
|
cd frontend
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
|
||||||
- name: Install backend dependencies
|
- name: Install backend dependencies
|
||||||
run: go mod download
|
run: |
|
||||||
|
go mod download
|
||||||
|
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
working-directory: ./frontend
|
run: |
|
||||||
run: pnpm run build
|
cd frontend
|
||||||
|
bun run build
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
+2
-2
@@ -7,7 +7,7 @@ Contributing to Tinyauth is straightforward. Follow the steps below to set up a
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- pnpm
|
- Bun
|
||||||
- Golang v1.24.0 or later
|
- Golang v1.24.0 or later
|
||||||
- Git
|
- Git
|
||||||
- Docker
|
- Docker
|
||||||
@@ -34,7 +34,7 @@ Frontend dependencies can be installed as follows:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd frontend/
|
cd frontend/
|
||||||
pnpm ci
|
bun install
|
||||||
```
|
```
|
||||||
|
|
||||||
## Create the `.env` file
|
## Create the `.env` file
|
||||||
|
|||||||
+4
-6
@@ -1,14 +1,12 @@
|
|||||||
# Site builder
|
# Site builder
|
||||||
FROM node:26.1-alpine3.23 AS frontend-builder
|
FROM oven/bun:1.3.13-alpine AS frontend-builder
|
||||||
|
|
||||||
WORKDIR /frontend
|
WORKDIR /frontend
|
||||||
|
|
||||||
RUN npm install -g pnpm@11.1.2
|
|
||||||
|
|
||||||
COPY ./frontend/package.json ./
|
COPY ./frontend/package.json ./
|
||||||
COPY ./frontend/pnpm-lock.yaml ./
|
COPY ./frontend/bun.lock ./
|
||||||
|
|
||||||
RUN pnpm ci
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
COPY ./frontend/public ./public
|
COPY ./frontend/public ./public
|
||||||
COPY ./frontend/src ./src
|
COPY ./frontend/src ./src
|
||||||
@@ -19,7 +17,7 @@ COPY ./frontend/tsconfig.app.json ./
|
|||||||
COPY ./frontend/tsconfig.node.json ./
|
COPY ./frontend/tsconfig.node.json ./
|
||||||
COPY ./frontend/vite.config.ts ./
|
COPY ./frontend/vite.config.ts ./
|
||||||
|
|
||||||
RUN pnpm run build
|
RUN bun run build
|
||||||
|
|
||||||
# Builder
|
# Builder
|
||||||
FROM golang:1.26-alpine3.23 AS builder
|
FROM golang:1.26-alpine3.23 AS builder
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ COPY go.sum ./
|
|||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
RUN go install github.com/air-verse/air@v1.61.7
|
RUN go install github.com/air-verse/air@v1.61.7
|
||||||
RUN go install github.com/go-delve/delve/cmd/dlv@v1.26.3
|
RUN go install github.com/go-delve/delve/cmd/dlv@latest
|
||||||
|
|
||||||
COPY ./cmd ./cmd
|
COPY ./cmd ./cmd
|
||||||
COPY ./internal ./internal
|
COPY ./internal ./internal
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
# Site builder
|
# Site builder
|
||||||
FROM node:26.1-alpine3.23 AS frontend-builder
|
FROM oven/bun:1.3.13-alpine AS frontend-builder
|
||||||
|
|
||||||
WORKDIR /frontend
|
WORKDIR /frontend
|
||||||
|
|
||||||
RUN npm install -g pnpm@11.1.2
|
|
||||||
|
|
||||||
COPY ./frontend/package.json ./
|
COPY ./frontend/package.json ./
|
||||||
COPY ./frontend/pnpm-lock.yaml ./
|
COPY ./frontend/bun.lock ./
|
||||||
|
|
||||||
RUN pnpm ci
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
COPY ./frontend/public ./public
|
COPY ./frontend/public ./public
|
||||||
COPY ./frontend/src ./src
|
COPY ./frontend/src ./src
|
||||||
@@ -19,7 +17,7 @@ COPY ./frontend/tsconfig.app.json ./
|
|||||||
COPY ./frontend/tsconfig.node.json ./
|
COPY ./frontend/tsconfig.node.json ./
|
||||||
COPY ./frontend/vite.config.ts ./
|
COPY ./frontend/vite.config.ts ./
|
||||||
|
|
||||||
RUN pnpm run build
|
RUN bun run build
|
||||||
|
|
||||||
# Builder
|
# Builder
|
||||||
FROM golang:1.26-alpine3.23 AS builder
|
FROM golang:1.26-alpine3.23 AS builder
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ PROD_COMPOSE := $(shell test -f "docker-compose.test.prod.yml" && echo "docker-c
|
|||||||
|
|
||||||
# Deps
|
# Deps
|
||||||
deps:
|
deps:
|
||||||
cd frontend && pnpm ci
|
bun install --frozen-lockfile --cwd frontend
|
||||||
go mod download
|
go mod download
|
||||||
|
|
||||||
# Clean data
|
# Clean data
|
||||||
@@ -31,7 +31,7 @@ clean-webui:
|
|||||||
|
|
||||||
# Build the web UI
|
# Build the web UI
|
||||||
webui: clean-webui
|
webui: clean-webui
|
||||||
cd frontend && pnpm run build
|
bun run --cwd frontend build
|
||||||
cp -r frontend/dist internal/assets
|
cp -r frontend/dist internal/assets
|
||||||
|
|
||||||
# Build the binary
|
# Build the binary
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Ignore artifacts:
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
bun.lock
|
||||||
|
package.json
|
||||||
|
src/lib/i18n/locales
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
FROM node:26.1-alpine3.23
|
FROM oven/bun:1.2.16-alpine
|
||||||
|
|
||||||
RUN npm install -g pnpm@11.1.2
|
|
||||||
|
|
||||||
WORKDIR /frontend
|
WORKDIR /frontend
|
||||||
|
|
||||||
COPY ./frontend/package.json ./
|
COPY ./frontend/package.json ./
|
||||||
COPY ./frontend/pnpm-lock.yaml ./
|
COPY ./frontend/bun.lock ./
|
||||||
|
|
||||||
RUN pnpm ci
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
COPY ./frontend/public ./public
|
COPY ./frontend/public ./public
|
||||||
COPY ./frontend/src ./src
|
COPY ./frontend/src ./src
|
||||||
@@ -21,4 +19,4 @@ COPY ./frontend/vite.config.ts ./
|
|||||||
|
|
||||||
EXPOSE 5173
|
EXPOSE 5173
|
||||||
|
|
||||||
ENTRYPOINT ["pnpm", "run", "dev"]
|
ENTRYPOINT ["bun", "run", "dev"]
|
||||||
|
|||||||
+1155
File diff suppressed because it is too large
Load Diff
+23
-24
@@ -10,7 +10,6 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tsc": "tsc -b"
|
"tsc": "tsc -b"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.1.2",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@@ -18,44 +17,44 @@
|
|||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@tanstack/react-query": "^5.99.0",
|
"@tanstack/react-query": "^5.100.10",
|
||||||
"axios": "^1.15.0",
|
"axios": "^1.16.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"i18next": "^26.0.4",
|
"i18next": "^26.2.0",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"i18next-resources-to-backend": "^1.2.1",
|
"i18next-resources-to-backend": "^1.2.1",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.16.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.6",
|
||||||
"react-hook-form": "^7.72.1",
|
"react-hook-form": "^7.75.0",
|
||||||
"react-i18next": "^17.0.2",
|
"react-i18next": "^17.0.8",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router": "^7.14.0",
|
"react-router": "^7.15.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.3.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@tanstack/eslint-plugin-query": "^5.99.0",
|
"@tanstack/eslint-plugin-query": "^5.100.10",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.8.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"eslint": "^10.2.0",
|
"eslint": "^10.3.0",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.5.0",
|
"globals": "^17.6.0",
|
||||||
"prettier": "3.8.2",
|
"prettier": "3.8.3",
|
||||||
"rollup-plugin-visualizer": "^7.0.1",
|
"rollup-plugin-visualizer": "^7.0.1",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.3",
|
||||||
"typescript-eslint": "^8.58.1",
|
"typescript-eslint": "^8.59.3",
|
||||||
"vite": "^8.0.8"
|
"vite": "^8.0.13"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
-5072
File diff suppressed because it is too large
Load Diff
@@ -1,4 +0,0 @@
|
|||||||
dangerouslyAllowAllBuilds: false
|
|
||||||
blockExoticSubdeps: true
|
|
||||||
minimumReleaseAge: 1440 # 1 day
|
|
||||||
trustPolicy: no-downgrade
|
|
||||||
@@ -34,7 +34,6 @@ type Services struct {
|
|||||||
ldapService *service.LdapService
|
ldapService *service.LdapService
|
||||||
oauthBrokerService *service.OAuthBrokerService
|
oauthBrokerService *service.OAuthBrokerService
|
||||||
oidcService *service.OIDCService
|
oidcService *service.OIDCService
|
||||||
policyEngine *service.PolicyEngine
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type BootstrapApp struct {
|
type BootstrapApp struct {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func (app *BootstrapApp) setupRouter() error {
|
|||||||
controller.NewContextController(app.log, app.config, app.runtime, apiRouter)
|
controller.NewContextController(app.log, app.config, app.runtime, apiRouter)
|
||||||
controller.NewOAuthController(app.log, app.config, app.runtime, apiRouter, app.services.authService)
|
controller.NewOAuthController(app.log, app.config, app.runtime, apiRouter, app.services.authService)
|
||||||
controller.NewOIDCController(app.log, app.services.oidcService, app.runtime, apiRouter)
|
controller.NewOIDCController(app.log, app.services.oidcService, app.runtime, apiRouter)
|
||||||
controller.NewProxyController(app.log, app.runtime, apiRouter, app.services.accessControlService, app.services.authService, app.services.policyEngine)
|
controller.NewProxyController(app.log, app.runtime, apiRouter, app.services.accessControlService, app.services.authService)
|
||||||
controller.NewUserController(app.log, app.runtime, apiRouter, app.services.authService)
|
controller.NewUserController(app.log, app.runtime, apiRouter, app.services.authService)
|
||||||
controller.NewResourcesController(app.config, &engine.RouterGroup)
|
controller.NewResourcesController(app.config, &engine.RouterGroup)
|
||||||
controller.NewHealthController(apiRouter)
|
controller.NewHealthController(apiRouter)
|
||||||
|
|||||||
@@ -16,21 +16,38 @@ func (app *BootstrapApp) setupServices() error {
|
|||||||
|
|
||||||
app.services.ldapService = ldapService
|
app.services.ldapService = ldapService
|
||||||
|
|
||||||
labelProvider, err := app.getLabelProvider()
|
useKubernetes := app.config.LabelProvider == "kubernetes" ||
|
||||||
|
(app.config.LabelProvider == "auto" && os.Getenv("KUBERNETES_SERVICE_HOST") != "")
|
||||||
|
|
||||||
|
var labelProvider service.LabelProvider
|
||||||
|
|
||||||
|
if useKubernetes {
|
||||||
|
app.log.App.Debug().Msg("Using Kubernetes label provider")
|
||||||
|
|
||||||
|
kubernetesService, err := service.NewKubernetesService(app.log, app.ctx, &app.wg)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to initialize label provider: %w", err)
|
return fmt.Errorf("failed to initialize kubernetes service: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
accessControlsService := service.NewAccessControlsService(app.log, app.config, &labelProvider)
|
app.services.kubernetesService = kubernetesService
|
||||||
|
labelProvider = kubernetesService
|
||||||
|
} else {
|
||||||
|
app.log.App.Debug().Msg("Using Docker label provider")
|
||||||
|
|
||||||
|
dockerService, err := service.NewDockerService(app.log, app.ctx, &app.wg)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize docker service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.services.dockerService = dockerService
|
||||||
|
labelProvider = dockerService
|
||||||
|
}
|
||||||
|
|
||||||
|
accessControlsService := service.NewAccessControlsService(app.log, &labelProvider, app.config.Apps)
|
||||||
app.services.accessControlService = accessControlsService
|
app.services.accessControlService = accessControlsService
|
||||||
|
|
||||||
err = app.setupPolicyEngine()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to initialize policy engine: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
oauthBrokerService := service.NewOAuthBrokerService(app.log, app.runtime.OAuthProviders, app.ctx)
|
oauthBrokerService := service.NewOAuthBrokerService(app.log, app.runtime.OAuthProviders, app.ctx)
|
||||||
app.services.oauthBrokerService = oauthBrokerService
|
app.services.oauthBrokerService = oauthBrokerService
|
||||||
|
|
||||||
@@ -47,67 +64,3 @@ func (app *BootstrapApp) setupServices() error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *BootstrapApp) getLabelProvider() (service.LabelProvider, error) {
|
|
||||||
if app.config.LabelProvider == "none" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
useKubernetes := app.config.LabelProvider == "kubernetes" ||
|
|
||||||
(app.config.LabelProvider == "auto" && os.Getenv("KUBERNETES_SERVICE_HOST") != "")
|
|
||||||
|
|
||||||
if useKubernetes {
|
|
||||||
app.log.App.Debug().Msg("Using Kubernetes label provider")
|
|
||||||
|
|
||||||
kubernetesService, err := service.NewKubernetesService(app.log, app.ctx, &app.wg)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to initialize kubernetes service: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
app.services.kubernetesService = kubernetesService
|
|
||||||
return kubernetesService, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
app.log.App.Debug().Msg("Using Docker label provider")
|
|
||||||
|
|
||||||
dockerService, err := service.NewDockerService(app.log, app.ctx, &app.wg)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to initialize docker service: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
app.services.dockerService = dockerService
|
|
||||||
return dockerService, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *BootstrapApp) setupPolicyEngine() error {
|
|
||||||
policyEngine, err := service.NewPolicyEngine(app.config, app.log)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to initialize policy engine: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
policyEngine.RegisterRule(service.RuleUserAllowed, &service.UserAllowedRule{
|
|
||||||
Log: app.log,
|
|
||||||
})
|
|
||||||
policyEngine.RegisterRule(service.RuleOAuthGroup, &service.OAuthGroupRule{
|
|
||||||
Log: app.log,
|
|
||||||
})
|
|
||||||
policyEngine.RegisterRule(service.RuleLDAPGroup, &service.LDAPGroupRule{
|
|
||||||
Log: app.log,
|
|
||||||
})
|
|
||||||
policyEngine.RegisterRule(service.RuleAuthEnabled, &service.AuthEnabledRule{
|
|
||||||
Log: app.log,
|
|
||||||
})
|
|
||||||
policyEngine.RegisterRule(service.RuleIPAllowed, &service.IPAllowedRule{
|
|
||||||
Log: app.log,
|
|
||||||
Config: app.config,
|
|
||||||
})
|
|
||||||
policyEngine.RegisterRule(service.RuleIPBypassed, &service.IPBypassedRule{
|
|
||||||
Log: app.log,
|
|
||||||
})
|
|
||||||
|
|
||||||
app.services.policyEngine = policyEngine
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -208,12 +208,7 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
|||||||
name = user.Name
|
name = user.Name
|
||||||
} else {
|
} else {
|
||||||
controller.log.App.Debug().Msg("No name from OAuth provider, generating from email")
|
controller.log.App.Debug().Msg("No name from OAuth provider, generating from email")
|
||||||
parts := strings.SplitN(user.Email, "@", 2)
|
name = fmt.Sprintf("%s (%s)", utils.Capitalize(strings.Split(user.Email, "@")[0]), strings.Split(user.Email, "@")[1])
|
||||||
if len(parts) == 2 {
|
|
||||||
name = fmt.Sprintf("%s (%s)", utils.Capitalize(parts[0]), parts[1])
|
|
||||||
} else {
|
|
||||||
name = utils.Capitalize(user.Email)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var username string
|
var username string
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
|
|||||||
client, ok := controller.oidc.GetClient(req.ClientID)
|
client, ok := controller.oidc.GetClient(req.ClientID)
|
||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
controller.authorizeError(c, fmt.Errorf("client not found: %s", req.ClientID), "Client not found", "The client ID is invalid", "", "", "")
|
controller.authorizeError(c, err, "Client not found", "The client ID is invalid", "", "", "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +288,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
|||||||
entry, err := controller.oidc.GetCodeEntry(c, controller.oidc.Hash(req.Code), client.ClientID)
|
entry, err := controller.oidc.GetCodeEntry(c, controller.oidc.Hash(req.Code), client.ClientID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err := controller.oidc.DeleteTokenByCodeHash(c, controller.oidc.Hash(req.Code)); err != nil {
|
if err := controller.oidc.DeleteTokenByCodeHash(c, controller.oidc.Hash(req.Code)); err != nil {
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to revoke tokens for replayed code")
|
controller.log.App.Error().Err(err).Msg("Failed to delete code")
|
||||||
}
|
}
|
||||||
if errors.Is(err, service.ErrCodeNotFound) {
|
if errors.Is(err, service.ErrCodeNotFound) {
|
||||||
controller.log.App.Warn().Msg("Code not found")
|
controller.log.App.Warn().Msg("Code not found")
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package controller
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -56,7 +55,6 @@ type ProxyController struct {
|
|||||||
runtime model.RuntimeConfig
|
runtime model.RuntimeConfig
|
||||||
acls *service.AccessControlsService
|
acls *service.AccessControlsService
|
||||||
auth *service.AuthService
|
auth *service.AuthService
|
||||||
policyEngine *service.PolicyEngine
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewProxyController(
|
func NewProxyController(
|
||||||
@@ -65,14 +63,12 @@ func NewProxyController(
|
|||||||
router *gin.RouterGroup,
|
router *gin.RouterGroup,
|
||||||
acls *service.AccessControlsService,
|
acls *service.AccessControlsService,
|
||||||
auth *service.AuthService,
|
auth *service.AuthService,
|
||||||
policyEngine *service.PolicyEngine,
|
|
||||||
) *ProxyController {
|
) *ProxyController {
|
||||||
controller := &ProxyController{
|
controller := &ProxyController{
|
||||||
log: log,
|
log: log,
|
||||||
runtime: runtime,
|
runtime: runtime,
|
||||||
acls: acls,
|
acls: acls,
|
||||||
auth: auth,
|
auth: auth,
|
||||||
policyEngine: policyEngine,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
proxyGroup := router.Group("/auth")
|
proxyGroup := router.Group("/auth")
|
||||||
@@ -105,13 +101,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
|||||||
|
|
||||||
clientIP := c.ClientIP()
|
clientIP := c.ClientIP()
|
||||||
|
|
||||||
aclsCtx := &service.ACLContext{
|
if controller.auth.IsBypassedIP(clientIP, acls) {
|
||||||
ACLs: acls,
|
|
||||||
IP: net.ParseIP(clientIP),
|
|
||||||
Path: proxyCtx.Path,
|
|
||||||
}
|
|
||||||
|
|
||||||
if controller.policyEngine.Evaluate(service.RuleIPBypassed, aclsCtx) {
|
|
||||||
controller.setHeaders(c, acls)
|
controller.setHeaders(c, acls)
|
||||||
c.JSON(200, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"status": 200,
|
"status": 200,
|
||||||
@@ -120,7 +110,15 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if controller.policyEngine.Evaluate(service.RuleAuthEnabled, aclsCtx) {
|
authEnabled, err := controller.auth.IsAuthEnabled(proxyCtx.Path, acls)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
controller.log.App.Error().Err(err).Msg("Failed to determine if authentication is enabled for resource")
|
||||||
|
controller.handleError(c, proxyCtx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !authEnabled {
|
||||||
controller.log.App.Debug().Msg("Authentication is disabled for this resource, allowing access without authentication")
|
controller.log.App.Debug().Msg("Authentication is disabled for this resource, allowing access without authentication")
|
||||||
controller.setHeaders(c, acls)
|
controller.setHeaders(c, acls)
|
||||||
c.JSON(200, gin.H{
|
c.JSON(200, gin.H{
|
||||||
@@ -130,7 +128,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !controller.policyEngine.Evaluate(service.RuleIPAllowed, aclsCtx) {
|
if !controller.auth.CheckIP(clientIP, acls) {
|
||||||
queries, err := query.Values(UnauthorizedQuery{
|
queries, err := query.Values(UnauthorizedQuery{
|
||||||
Resource: strings.Split(proxyCtx.Host, ".")[0],
|
Resource: strings.Split(proxyCtx.Host, ".")[0],
|
||||||
IP: clientIP,
|
IP: clientIP,
|
||||||
@@ -146,9 +144,9 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
|||||||
|
|
||||||
if !controller.useBrowserResponse(proxyCtx) {
|
if !controller.useBrowserResponse(proxyCtx) {
|
||||||
c.Header("x-tinyauth-location", redirectURL)
|
c.Header("x-tinyauth-location", redirectURL)
|
||||||
c.JSON(403, gin.H{
|
c.JSON(401, gin.H{
|
||||||
"status": 403,
|
"status": 401,
|
||||||
"message": "Forbidden",
|
"message": "Unauthorized",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -166,10 +164,10 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
aclsCtx.UserContext = userContext
|
|
||||||
|
|
||||||
if userContext.Authenticated {
|
if userContext.Authenticated {
|
||||||
if !controller.policyEngine.Evaluate(service.RuleUserAllowed, aclsCtx) {
|
userAllowed := controller.auth.IsUserAllowed(c, *userContext, acls)
|
||||||
|
|
||||||
|
if !userAllowed {
|
||||||
controller.log.App.Warn().Str("user", userContext.GetUsername()).Str("resource", strings.Split(proxyCtx.Host, ".")[0]).Msg("User is not allowed to access resource")
|
controller.log.App.Warn().Str("user", userContext.GetUsername()).Str("resource", strings.Split(proxyCtx.Host, ".")[0]).Msg("User is not allowed to access resource")
|
||||||
|
|
||||||
queries, err := query.Values(UnauthorizedQuery{
|
queries, err := query.Values(UnauthorizedQuery{
|
||||||
@@ -207,9 +205,9 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
|||||||
var groupOK bool
|
var groupOK bool
|
||||||
|
|
||||||
if userContext.IsOAuth() {
|
if userContext.IsOAuth() {
|
||||||
groupOK = controller.policyEngine.Evaluate(service.RuleOAuthGroup, aclsCtx)
|
groupOK = controller.auth.IsInOAuthGroup(c, *userContext, acls)
|
||||||
} else {
|
} else {
|
||||||
groupOK = controller.policyEngine.Evaluate(service.RuleLDAPGroup, aclsCtx)
|
groupOK = controller.auth.IsInLDAPGroup(c, *userContext, acls)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !groupOK {
|
if !groupOK {
|
||||||
|
|||||||
@@ -24,6 +24,33 @@ func TestProxyController(t *testing.T) {
|
|||||||
|
|
||||||
cfg, runtime := test.CreateTestConfigs(t)
|
cfg, runtime := test.CreateTestConfigs(t)
|
||||||
|
|
||||||
|
acls := map[string]model.App{
|
||||||
|
"app_path_allow": {
|
||||||
|
Config: model.AppConfig{
|
||||||
|
Domain: "path-allow.example.com",
|
||||||
|
},
|
||||||
|
Path: model.AppPath{
|
||||||
|
Allow: "/allowed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"app_user_allow": {
|
||||||
|
Config: model.AppConfig{
|
||||||
|
Domain: "user-allow.example.com",
|
||||||
|
},
|
||||||
|
Users: model.AppUsers{
|
||||||
|
Allow: "testuser",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"ip_bypass": {
|
||||||
|
Config: model.AppConfig{
|
||||||
|
Domain: "ip-bypass.example.com",
|
||||||
|
},
|
||||||
|
IP: model.AppIP{
|
||||||
|
Bypass: []string{"10.10.10.10"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
const browserUserAgent = `
|
const browserUserAgent = `
|
||||||
Mozilla/5.0 (Linux; Android 8.0.0; SM-G955U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36`
|
Mozilla/5.0 (Linux; Android 8.0.0; SM-G955U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36`
|
||||||
|
|
||||||
@@ -364,29 +391,7 @@ func TestProxyController(t *testing.T) {
|
|||||||
|
|
||||||
broker := service.NewOAuthBrokerService(log, map[string]model.OAuthServiceConfig{}, ctx)
|
broker := service.NewOAuthBrokerService(log, map[string]model.OAuthServiceConfig{}, ctx)
|
||||||
authService := service.NewAuthService(log, cfg, runtime, ctx, wg, nil, queries, broker)
|
authService := service.NewAuthService(log, cfg, runtime, ctx, wg, nil, queries, broker)
|
||||||
aclsService := service.NewAccessControlsService(log, cfg, nil)
|
aclsService := service.NewAccessControlsService(log, nil, acls)
|
||||||
policyEngine, err := service.NewPolicyEngine(cfg, log)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
policyEngine.RegisterRule(service.RuleUserAllowed, &service.UserAllowedRule{
|
|
||||||
Log: log,
|
|
||||||
})
|
|
||||||
policyEngine.RegisterRule(service.RuleOAuthGroup, &service.OAuthGroupRule{
|
|
||||||
Log: log,
|
|
||||||
})
|
|
||||||
policyEngine.RegisterRule(service.RuleLDAPGroup, &service.LDAPGroupRule{
|
|
||||||
Log: log,
|
|
||||||
})
|
|
||||||
policyEngine.RegisterRule(service.RuleAuthEnabled, &service.AuthEnabledRule{
|
|
||||||
Log: log,
|
|
||||||
})
|
|
||||||
policyEngine.RegisterRule(service.RuleIPAllowed, &service.IPAllowedRule{
|
|
||||||
Log: log,
|
|
||||||
Config: cfg,
|
|
||||||
})
|
|
||||||
policyEngine.RegisterRule(service.RuleIPBypassed, &service.IPBypassedRule{
|
|
||||||
Log: log,
|
|
||||||
})
|
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.description, func(t *testing.T) {
|
t.Run(test.description, func(t *testing.T) {
|
||||||
@@ -401,7 +406,7 @@ func TestProxyController(t *testing.T) {
|
|||||||
|
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
controller.NewProxyController(log, runtime, group, aclsService, authService, policyEngine)
|
controller.NewProxyController(log, runtime, group, aclsService, authService)
|
||||||
|
|
||||||
test.run(t, router, recorder)
|
test.run(t, router, recorder)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func (controller *ResourcesController) resourcesHandler(c *gin.Context) {
|
|||||||
if controller.config.Resources.Path == "" {
|
if controller.config.Resources.Path == "" {
|
||||||
c.JSON(404, gin.H{
|
c.JSON(404, gin.H{
|
||||||
"status": 404,
|
"status": 404,
|
||||||
"message": "Resource not found",
|
"message": "Resources not found",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,6 @@ func NewDefaultConfiguration() *Config {
|
|||||||
SessionMaxLifetime: 0, // disabled
|
SessionMaxLifetime: 0, // disabled
|
||||||
LoginTimeout: 300, // 5 minutes
|
LoginTimeout: 300, // 5 minutes
|
||||||
LoginMaxRetries: 3,
|
LoginMaxRetries: 3,
|
||||||
ACLs: ACLsConfig{
|
|
||||||
Policy: "allow",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
UI: UIConfig{
|
UI: UIConfig{
|
||||||
Title: "Tinyauth",
|
Title: "Tinyauth",
|
||||||
@@ -81,7 +78,7 @@ type Config struct {
|
|||||||
UI UIConfig `description:"UI customization." yaml:"ui"`
|
UI UIConfig `description:"UI customization." yaml:"ui"`
|
||||||
LDAP LDAPConfig `description:"LDAP configuration." yaml:"ldap"`
|
LDAP LDAPConfig `description:"LDAP configuration." yaml:"ldap"`
|
||||||
Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental"`
|
Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental"`
|
||||||
LabelProvider string `description:"Label provider to use for ACLs (auto, docker, kubernetes or none to disable). auto detects the environment." yaml:"labelProvider"`
|
LabelProvider string `description:"Label provider to use for ACLs (auto, docker, or kubernetes). auto detects the environment." yaml:"labelProvider"`
|
||||||
Log LogConfig `description:"Logging configuration." yaml:"log"`
|
Log LogConfig `description:"Logging configuration." yaml:"log"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +114,6 @@ type AuthConfig struct {
|
|||||||
LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"`
|
LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"`
|
||||||
LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"`
|
LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"`
|
||||||
TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies"`
|
TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies"`
|
||||||
ACLs ACLsConfig `description:"ACLs configuration." yaml:"acls"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserAttributes struct {
|
type UserAttributes struct {
|
||||||
@@ -227,10 +223,6 @@ type OIDCClientConfig struct {
|
|||||||
Name string `description:"Client name in UI." yaml:"name"`
|
Name string `description:"Client name in UI." yaml:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ACLsConfig struct {
|
|
||||||
Policy string `description:"ACL policy for allow-by-default or deny-by-default, available options are allow and deny, default is allow." yaml:"policy"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ACLs
|
// ACLs
|
||||||
|
|
||||||
type Apps struct {
|
type Apps struct {
|
||||||
|
|||||||
@@ -1,702 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestUserAllowedRule(t *testing.T) {
|
|
||||||
log := logger.NewLogger().WithTestConfig()
|
|
||||||
log.Init()
|
|
||||||
|
|
||||||
rule := &UserAllowedRule{Log: log}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
ctx *ACLContext
|
|
||||||
expected Effect
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "abstains when ACLs are nil",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: nil,
|
|
||||||
UserContext: &model.UserContext{
|
|
||||||
Provider: model.ProviderLocal,
|
|
||||||
Local: &model.LocalContext{
|
|
||||||
BaseContext: model.BaseContext{Username: "alice"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: EffectAbstain,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "allows OAuth user when email matches whitelist",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
OAuth: model.AppOAuth{Whitelist: "allowed@example.com"},
|
|
||||||
},
|
|
||||||
UserContext: &model.UserContext{
|
|
||||||
Provider: model.ProviderOAuth,
|
|
||||||
OAuth: &model.OAuthContext{
|
|
||||||
BaseContext: model.BaseContext{
|
|
||||||
Username: "different-username",
|
|
||||||
Email: "allowed@example.com",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: EffectAllow,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "denies OAuth user when email does not match whitelist",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
OAuth: model.AppOAuth{Whitelist: "allowed@example.com"},
|
|
||||||
},
|
|
||||||
UserContext: &model.UserContext{
|
|
||||||
Provider: model.ProviderOAuth,
|
|
||||||
OAuth: &model.OAuthContext{
|
|
||||||
BaseContext: model.BaseContext{Email: "denied@example.com"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: EffectDeny,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "abstains for OAuth user when whitelist filter is invalid",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
OAuth: model.AppOAuth{Whitelist: "/[/"},
|
|
||||||
},
|
|
||||||
UserContext: &model.UserContext{
|
|
||||||
Provider: model.ProviderOAuth,
|
|
||||||
OAuth: &model.OAuthContext{
|
|
||||||
BaseContext: model.BaseContext{Email: "allowed@example.com"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: EffectAbstain,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "denies local user when username matches block list",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
Users: model.AppUsers{Block: "alice,bob"},
|
|
||||||
},
|
|
||||||
UserContext: &model.UserContext{
|
|
||||||
Provider: model.ProviderLocal,
|
|
||||||
Local: &model.LocalContext{
|
|
||||||
BaseContext: model.BaseContext{Username: "alice"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: EffectDeny,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "allows local user when username does not match block list",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
Users: model.AppUsers{Block: "alice,bob"},
|
|
||||||
},
|
|
||||||
UserContext: &model.UserContext{
|
|
||||||
Provider: model.ProviderLocal,
|
|
||||||
Local: &model.LocalContext{
|
|
||||||
BaseContext: model.BaseContext{Username: "charlie"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: EffectAllow,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "abstains when block list filter is invalid",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
Users: model.AppUsers{Block: "/[/"},
|
|
||||||
},
|
|
||||||
UserContext: &model.UserContext{
|
|
||||||
Provider: model.ProviderLocal,
|
|
||||||
Local: &model.LocalContext{
|
|
||||||
BaseContext: model.BaseContext{Username: "alice"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: EffectAbstain,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "allows local user when username matches allow list",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
Users: model.AppUsers{Allow: "alice,bob"},
|
|
||||||
},
|
|
||||||
UserContext: &model.UserContext{
|
|
||||||
Provider: model.ProviderLocal,
|
|
||||||
Local: &model.LocalContext{
|
|
||||||
BaseContext: model.BaseContext{Username: "alice"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: EffectAllow,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "denies local user when username does not match allow list",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
Users: model.AppUsers{Allow: "alice,bob"},
|
|
||||||
},
|
|
||||||
UserContext: &model.UserContext{
|
|
||||||
Provider: model.ProviderLocal,
|
|
||||||
Local: &model.LocalContext{
|
|
||||||
BaseContext: model.BaseContext{Username: "charlie"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: EffectDeny,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "abstains when allow list filter is invalid",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
Users: model.AppUsers{Allow: "/[/"},
|
|
||||||
},
|
|
||||||
UserContext: &model.UserContext{
|
|
||||||
Provider: model.ProviderLocal,
|
|
||||||
Local: &model.LocalContext{
|
|
||||||
BaseContext: model.BaseContext{Username: "alice"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: EffectAbstain,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
assert.Equal(t, tt.expected, rule.Evaluate(tt.ctx))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOAuthGroupRule(t *testing.T) {
|
|
||||||
log := logger.NewLogger().WithTestConfig()
|
|
||||||
log.Init()
|
|
||||||
|
|
||||||
rule := &OAuthGroupRule{Log: log}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
ctx *ACLContext
|
|
||||||
expected Effect
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "abstains when ACLs are nil",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: nil,
|
|
||||||
UserContext: &model.UserContext{
|
|
||||||
Provider: model.ProviderOAuth,
|
|
||||||
OAuth: &model.OAuthContext{
|
|
||||||
Groups: []string{"admins"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: EffectAbstain,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "abstains when user is not OAuth",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
OAuth: model.AppOAuth{Groups: "admins"},
|
|
||||||
},
|
|
||||||
UserContext: &model.UserContext{
|
|
||||||
Provider: model.ProviderLocal,
|
|
||||||
Local: &model.LocalContext{
|
|
||||||
BaseContext: model.BaseContext{Username: "alice"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: EffectAbstain,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "allows when provider is an override provider regardless of groups",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
OAuth: model.AppOAuth{Groups: "admins"},
|
|
||||||
},
|
|
||||||
UserContext: &model.UserContext{
|
|
||||||
Provider: model.ProviderOAuth,
|
|
||||||
OAuth: &model.OAuthContext{
|
|
||||||
ID: "google",
|
|
||||||
Groups: []string{"unrelated"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: EffectAllow,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "allows OAuth user when a group matches",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
OAuth: model.AppOAuth{Groups: "admins,users"},
|
|
||||||
},
|
|
||||||
UserContext: &model.UserContext{
|
|
||||||
Provider: model.ProviderOAuth,
|
|
||||||
OAuth: &model.OAuthContext{
|
|
||||||
ID: "custom",
|
|
||||||
Groups: []string{"users"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: EffectAllow,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "denies OAuth user when no group matches",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
OAuth: model.AppOAuth{Groups: "admins"},
|
|
||||||
},
|
|
||||||
UserContext: &model.UserContext{
|
|
||||||
Provider: model.ProviderOAuth,
|
|
||||||
OAuth: &model.OAuthContext{
|
|
||||||
ID: "custom",
|
|
||||||
Groups: []string{"users", "guests"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: EffectDeny,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "denies OAuth user when user has no groups",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
OAuth: model.AppOAuth{Groups: "admins"},
|
|
||||||
},
|
|
||||||
UserContext: &model.UserContext{
|
|
||||||
Provider: model.ProviderOAuth,
|
|
||||||
OAuth: &model.OAuthContext{
|
|
||||||
ID: "custom",
|
|
||||||
Groups: nil,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: EffectDeny,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "abstains when groups filter is invalid",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
OAuth: model.AppOAuth{Groups: "/[/"},
|
|
||||||
},
|
|
||||||
UserContext: &model.UserContext{
|
|
||||||
Provider: model.ProviderOAuth,
|
|
||||||
OAuth: &model.OAuthContext{
|
|
||||||
ID: "custom",
|
|
||||||
Groups: []string{"admins"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: EffectAbstain,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
assert.Equal(t, tt.expected, rule.Evaluate(tt.ctx))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLDAPGroupRule(t *testing.T) {
|
|
||||||
log := logger.NewLogger().WithTestConfig()
|
|
||||||
log.Init()
|
|
||||||
|
|
||||||
rule := &LDAPGroupRule{Log: log}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
ctx *ACLContext
|
|
||||||
expected Effect
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "abstains when context is nil",
|
|
||||||
ctx: nil,
|
|
||||||
expected: EffectAbstain,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "abstains when user is not LDAP",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
LDAP: model.AppLDAP{Groups: "admins"},
|
|
||||||
},
|
|
||||||
UserContext: &model.UserContext{
|
|
||||||
Provider: model.ProviderLocal,
|
|
||||||
Local: &model.LocalContext{
|
|
||||||
BaseContext: model.BaseContext{Username: "alice"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: EffectAbstain,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "allows LDAP user when a group matches",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
LDAP: model.AppLDAP{Groups: "admins,users"},
|
|
||||||
},
|
|
||||||
UserContext: &model.UserContext{
|
|
||||||
Provider: model.ProviderLDAP,
|
|
||||||
LDAP: &model.LDAPContext{
|
|
||||||
Groups: []string{"users"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: EffectAllow,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "denies LDAP user when no group matches",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
LDAP: model.AppLDAP{Groups: "admins"},
|
|
||||||
},
|
|
||||||
UserContext: &model.UserContext{
|
|
||||||
Provider: model.ProviderLDAP,
|
|
||||||
LDAP: &model.LDAPContext{
|
|
||||||
Groups: []string{"users", "guests"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: EffectDeny,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "denies LDAP user when user has no groups",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
LDAP: model.AppLDAP{Groups: "admins"},
|
|
||||||
},
|
|
||||||
UserContext: &model.UserContext{
|
|
||||||
Provider: model.ProviderLDAP,
|
|
||||||
LDAP: &model.LDAPContext{
|
|
||||||
Groups: nil,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: EffectDeny,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "abstains when groups filter is invalid",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
LDAP: model.AppLDAP{Groups: "/[/"},
|
|
||||||
},
|
|
||||||
UserContext: &model.UserContext{
|
|
||||||
Provider: model.ProviderLDAP,
|
|
||||||
LDAP: &model.LDAPContext{
|
|
||||||
Groups: []string{"admins"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: EffectAbstain,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
assert.Equal(t, tt.expected, rule.Evaluate(tt.ctx))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthEnabledRule(t *testing.T) {
|
|
||||||
log := logger.NewLogger().WithTestConfig()
|
|
||||||
log.Init()
|
|
||||||
|
|
||||||
rule := &AuthEnabledRule{Log: log}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
ctx *ACLContext
|
|
||||||
expected Effect
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "deny when ACLs are nil",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: nil,
|
|
||||||
Path: "/anything",
|
|
||||||
},
|
|
||||||
expected: EffectDeny,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "allows when path does not match block regex",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
Path: model.AppPath{Block: "^/admin"},
|
|
||||||
},
|
|
||||||
Path: "/public",
|
|
||||||
},
|
|
||||||
expected: EffectAllow,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "denies when path matches block regex and no allow regex",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
Path: model.AppPath{Block: "^/admin"},
|
|
||||||
},
|
|
||||||
Path: "/admin/users",
|
|
||||||
},
|
|
||||||
expected: EffectDeny,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "allows when path matches allow regex",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
Path: model.AppPath{Allow: "^/public"},
|
|
||||||
},
|
|
||||||
Path: "/public/index",
|
|
||||||
},
|
|
||||||
expected: EffectAllow,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "denies when path does not match allow regex",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
Path: model.AppPath{Allow: "^/public"},
|
|
||||||
},
|
|
||||||
Path: "/private",
|
|
||||||
},
|
|
||||||
expected: EffectDeny,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "allows when blocked path is also explicitly allowed",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
Path: model.AppPath{
|
|
||||||
Block: "^/admin",
|
|
||||||
Allow: "^/admin/public",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Path: "/admin/public/page",
|
|
||||||
},
|
|
||||||
expected: EffectAllow,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "denies when block regex fails to compile",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
Path: model.AppPath{Block: "[invalid"},
|
|
||||||
},
|
|
||||||
Path: "/anything",
|
|
||||||
},
|
|
||||||
expected: EffectDeny,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "denies when allow regex fails to compile",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
Path: model.AppPath{Allow: "[invalid"},
|
|
||||||
},
|
|
||||||
Path: "/anything",
|
|
||||||
},
|
|
||||||
expected: EffectDeny,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "denies when no path rules are configured",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{},
|
|
||||||
Path: "/anything",
|
|
||||||
},
|
|
||||||
expected: EffectDeny,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
assert.Equal(t, tt.expected, rule.Evaluate(tt.ctx))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIPAllowedRule(t *testing.T) {
|
|
||||||
log := logger.NewLogger().WithTestConfig()
|
|
||||||
log.Init()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
config model.Config
|
|
||||||
ctx *ACLContext
|
|
||||||
expected Effect
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "abstains when ACLs are nil",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: nil,
|
|
||||||
IP: net.ParseIP("10.0.0.1"),
|
|
||||||
},
|
|
||||||
expected: EffectAbstain,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "denies when IP matches app block list",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
IP: model.AppIP{Block: []string{"10.0.0.1"}},
|
|
||||||
},
|
|
||||||
IP: net.ParseIP("10.0.0.1"),
|
|
||||||
},
|
|
||||||
expected: EffectDeny,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "denies when IP matches global block list",
|
|
||||||
config: model.Config{
|
|
||||||
Auth: model.AuthConfig{
|
|
||||||
IP: model.IPConfig{Block: []string{"10.0.0.0/24"}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{},
|
|
||||||
IP: net.ParseIP("10.0.0.5"),
|
|
||||||
},
|
|
||||||
expected: EffectDeny,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "allows when IP matches app allow list",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
IP: model.AppIP{Allow: []string{"192.168.1.0/24"}},
|
|
||||||
},
|
|
||||||
IP: net.ParseIP("192.168.1.10"),
|
|
||||||
},
|
|
||||||
expected: EffectAllow,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "allows when IP matches global allow list",
|
|
||||||
config: model.Config{
|
|
||||||
Auth: model.AuthConfig{
|
|
||||||
IP: model.IPConfig{Allow: []string{"192.168.1.10"}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{},
|
|
||||||
IP: net.ParseIP("192.168.1.10"),
|
|
||||||
},
|
|
||||||
expected: EffectAllow,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "denies when allow list is set and IP does not match",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
IP: model.AppIP{Allow: []string{"192.168.1.0/24"}},
|
|
||||||
},
|
|
||||||
IP: net.ParseIP("10.0.0.1"),
|
|
||||||
},
|
|
||||||
expected: EffectDeny,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "allows when no block or allow lists are configured",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{},
|
|
||||||
IP: net.ParseIP("10.0.0.1"),
|
|
||||||
},
|
|
||||||
expected: EffectAllow,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "block list takes precedence over allow list",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
IP: model.AppIP{
|
|
||||||
Block: []string{"10.0.0.1"},
|
|
||||||
Allow: []string{"10.0.0.1"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
IP: net.ParseIP("10.0.0.1"),
|
|
||||||
},
|
|
||||||
expected: EffectDeny,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "skips invalid block entries and continues evaluation",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
IP: model.AppIP{
|
|
||||||
Block: []string{"not-an-ip"},
|
|
||||||
Allow: []string{"10.0.0.1"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
IP: net.ParseIP("10.0.0.1"),
|
|
||||||
},
|
|
||||||
expected: EffectAllow,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
rule := &IPAllowedRule{Log: log, Config: tt.config}
|
|
||||||
assert.Equal(t, tt.expected, rule.Evaluate(tt.ctx))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIPBypassedRule(t *testing.T) {
|
|
||||||
log := logger.NewLogger().WithTestConfig()
|
|
||||||
log.Init()
|
|
||||||
|
|
||||||
rule := &IPBypassedRule{Log: log}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
ctx *ACLContext
|
|
||||||
expected Effect
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "deny when ACLs are nil",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: nil,
|
|
||||||
IP: net.ParseIP("10.0.0.1"),
|
|
||||||
},
|
|
||||||
expected: EffectDeny,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "allows when IP matches bypass list",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}},
|
|
||||||
},
|
|
||||||
IP: net.ParseIP("10.0.0.5"),
|
|
||||||
},
|
|
||||||
expected: EffectAllow,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "denies when IP does not match bypass list",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}},
|
|
||||||
},
|
|
||||||
IP: net.ParseIP("192.168.1.1"),
|
|
||||||
},
|
|
||||||
expected: EffectDeny,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "denies when bypass list is empty",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{},
|
|
||||||
IP: net.ParseIP("10.0.0.1"),
|
|
||||||
},
|
|
||||||
expected: EffectDeny,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "skips invalid bypass entries and allows on later match",
|
|
||||||
ctx: &ACLContext{
|
|
||||||
ACLs: &model.App{
|
|
||||||
IP: model.AppIP{Bypass: []string{"not-an-ip", "10.0.0.1"}},
|
|
||||||
},
|
|
||||||
IP: net.ParseIP("10.0.0.1"),
|
|
||||||
},
|
|
||||||
expected: EffectAllow,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
assert.Equal(t, tt.expected, rule.Evaluate(tt.ctx))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
type RuleName string
|
|
||||||
|
|
||||||
const (
|
|
||||||
RuleUserAllowed RuleName = "rule-user-allowed"
|
|
||||||
RuleOAuthGroup RuleName = "rule-oauth-group"
|
|
||||||
RuleLDAPGroup RuleName = "rule-ldap-group"
|
|
||||||
RuleAuthEnabled RuleName = "rule-auth-enabled"
|
|
||||||
RuleIPAllowed RuleName = "rule-ip-allowed"
|
|
||||||
RuleIPBypassed RuleName = "rule-ip-bypassed"
|
|
||||||
)
|
|
||||||
|
|
||||||
type UserAllowedRule struct {
|
|
||||||
Log *logger.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rule *UserAllowedRule) Evaluate(ctx *ACLContext) Effect {
|
|
||||||
if ctx.ACLs == nil {
|
|
||||||
return EffectAbstain
|
|
||||||
}
|
|
||||||
|
|
||||||
if ctx.UserContext.Provider == model.ProviderOAuth {
|
|
||||||
rule.Log.App.Debug().Msg("User is an OAuth user, checking OAuth whitelist")
|
|
||||||
match, err := utils.CheckFilter(ctx.ACLs.OAuth.Whitelist, ctx.UserContext.OAuth.Email)
|
|
||||||
if err != nil {
|
|
||||||
rule.Log.App.Warn().Err(err).Str("item", ctx.UserContext.OAuth.Email).Msg("Invalid entry in OAuth whitelist")
|
|
||||||
return EffectAbstain
|
|
||||||
}
|
|
||||||
if match {
|
|
||||||
rule.Log.App.Debug().Str("email", ctx.UserContext.OAuth.Email).Msg("User is in OAuth whitelist, allowing access")
|
|
||||||
return EffectAllow
|
|
||||||
}
|
|
||||||
return EffectDeny
|
|
||||||
}
|
|
||||||
|
|
||||||
if ctx.ACLs.Users.Block != "" {
|
|
||||||
rule.Log.App.Debug().Msg("Checking users block list")
|
|
||||||
match, err := utils.CheckFilter(ctx.ACLs.Users.Block, ctx.UserContext.GetUsername())
|
|
||||||
if err != nil {
|
|
||||||
rule.Log.App.Warn().Err(err).Str("item", ctx.UserContext.GetUsername()).Msg("Invalid entry in users block list")
|
|
||||||
return EffectAbstain
|
|
||||||
}
|
|
||||||
if match {
|
|
||||||
rule.Log.App.Debug().Str("username", ctx.UserContext.GetUsername()).Msg("User is in users block list, denying access")
|
|
||||||
return EffectDeny
|
|
||||||
}
|
|
||||||
return EffectAllow
|
|
||||||
}
|
|
||||||
|
|
||||||
rule.Log.App.Debug().Msg("Checking users allow list")
|
|
||||||
|
|
||||||
match, err := utils.CheckFilter(ctx.ACLs.Users.Allow, ctx.UserContext.GetUsername())
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
rule.Log.App.Warn().Err(err).Str("item", ctx.UserContext.GetUsername()).Msg("Invalid entry in users allow list")
|
|
||||||
return EffectAbstain
|
|
||||||
}
|
|
||||||
|
|
||||||
if match {
|
|
||||||
rule.Log.App.Debug().Str("username", ctx.UserContext.GetUsername()).Msg("User is in users allow list, allowing access")
|
|
||||||
return EffectAllow
|
|
||||||
}
|
|
||||||
|
|
||||||
rule.Log.App.Debug().Str("username", ctx.UserContext.GetUsername()).Msg("User is not in users allow list, denying access")
|
|
||||||
return EffectDeny
|
|
||||||
}
|
|
||||||
|
|
||||||
type OAuthGroupRule struct {
|
|
||||||
Log *logger.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rule *OAuthGroupRule) Evaluate(ctx *ACLContext) Effect {
|
|
||||||
if ctx.ACLs == nil {
|
|
||||||
return EffectAbstain
|
|
||||||
}
|
|
||||||
|
|
||||||
if !ctx.UserContext.IsOAuth() {
|
|
||||||
rule.Log.App.Debug().Msg("User is not an OAuth user, skipping OAuth group check")
|
|
||||||
return EffectAbstain
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := model.OverrideProviders[ctx.UserContext.OAuth.ID]; ok {
|
|
||||||
rule.Log.App.Debug().Str("provider", ctx.UserContext.OAuth.ID).Msg("Provider override detected, skipping group check")
|
|
||||||
return EffectAllow
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, group := range ctx.UserContext.OAuth.Groups {
|
|
||||||
match, err := utils.CheckFilter(ctx.ACLs.OAuth.Groups, strings.TrimSpace(group))
|
|
||||||
if err != nil {
|
|
||||||
return EffectAbstain
|
|
||||||
}
|
|
||||||
if match {
|
|
||||||
rule.Log.App.Trace().Str("group", group).Str("required", ctx.ACLs.OAuth.Groups).Msg("User group matched, allowing access")
|
|
||||||
return EffectAllow
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rule.Log.App.Debug().Msg("No groups matched")
|
|
||||||
return EffectDeny
|
|
||||||
}
|
|
||||||
|
|
||||||
type LDAPGroupRule struct {
|
|
||||||
Log *logger.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rule *LDAPGroupRule) Evaluate(ctx *ACLContext) Effect {
|
|
||||||
if ctx == nil {
|
|
||||||
return EffectAbstain
|
|
||||||
}
|
|
||||||
|
|
||||||
if !ctx.UserContext.IsLDAP() {
|
|
||||||
rule.Log.App.Debug().Msg("User is not an LDAP user, skipping LDAP group check")
|
|
||||||
return EffectAbstain
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, group := range ctx.UserContext.LDAP.Groups {
|
|
||||||
match, err := utils.CheckFilter(ctx.ACLs.LDAP.Groups, strings.TrimSpace(group))
|
|
||||||
if err != nil {
|
|
||||||
return EffectAbstain
|
|
||||||
}
|
|
||||||
if match {
|
|
||||||
rule.Log.App.Trace().Str("group", group).Str("required", ctx.ACLs.LDAP.Groups).Msg("User group matched, allowing access")
|
|
||||||
return EffectAllow
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rule.Log.App.Debug().Msg("No groups matched")
|
|
||||||
return EffectDeny
|
|
||||||
}
|
|
||||||
|
|
||||||
type AuthEnabledRule struct {
|
|
||||||
Log *logger.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rule *AuthEnabledRule) Evaluate(ctx *ACLContext) Effect {
|
|
||||||
if ctx.ACLs == nil {
|
|
||||||
return EffectDeny
|
|
||||||
}
|
|
||||||
|
|
||||||
if ctx.ACLs.Path.Block != "" {
|
|
||||||
regex, err := regexp.Compile(ctx.ACLs.Path.Block)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
rule.Log.App.Error().Err(err).Msg("Failed to compile block regex")
|
|
||||||
return EffectDeny
|
|
||||||
}
|
|
||||||
|
|
||||||
if !regex.MatchString(ctx.Path) {
|
|
||||||
return EffectAllow
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ctx.ACLs.Path.Allow != "" {
|
|
||||||
regex, err := regexp.Compile(ctx.ACLs.Path.Allow)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
rule.Log.App.Error().Err(err).Msg("Failed to compile allow regex")
|
|
||||||
return EffectDeny
|
|
||||||
}
|
|
||||||
|
|
||||||
if regex.MatchString(ctx.Path) {
|
|
||||||
return EffectAllow
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return EffectDeny
|
|
||||||
}
|
|
||||||
|
|
||||||
type IPAllowedRule struct {
|
|
||||||
Log *logger.Logger
|
|
||||||
Config model.Config
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rule *IPAllowedRule) Evaluate(ctx *ACLContext) Effect {
|
|
||||||
if ctx.ACLs == nil {
|
|
||||||
return EffectAbstain
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge the global and app IP filter
|
|
||||||
blockedIps := append(ctx.ACLs.IP.Block, rule.Config.Auth.IP.Block...)
|
|
||||||
allowedIPs := append(ctx.ACLs.IP.Allow, rule.Config.Auth.IP.Allow...)
|
|
||||||
|
|
||||||
for _, blocked := range blockedIps {
|
|
||||||
match, err := utils.CheckIPFilter(blocked, ctx.IP.String())
|
|
||||||
if err != nil {
|
|
||||||
rule.Log.App.Warn().Err(err).Str("item", blocked).Msg("Invalid IP/CIDR in block list")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if match {
|
|
||||||
rule.Log.App.Debug().Str("ip", ctx.IP.String()).Str("item", blocked).Msg("IP is in block list, denying access")
|
|
||||||
return EffectDeny
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, allowed := range allowedIPs {
|
|
||||||
match, err := utils.CheckIPFilter(allowed, ctx.IP.String())
|
|
||||||
if err != nil {
|
|
||||||
rule.Log.App.Warn().Err(err).Str("item", allowed).Msg("Invalid IP/CIDR in allow list")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if match {
|
|
||||||
rule.Log.App.Debug().Str("ip", ctx.IP.String()).Str("item", allowed).Msg("IP is in allow list, allowing access")
|
|
||||||
return EffectAllow
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(allowedIPs) > 0 {
|
|
||||||
rule.Log.App.Debug().Str("ip", ctx.IP.String()).Msg("IP not in allow list, denying access")
|
|
||||||
return EffectDeny
|
|
||||||
}
|
|
||||||
|
|
||||||
rule.Log.App.Debug().Str("ip", ctx.IP.String()).Msg("IP not in block or allow list, allowing access")
|
|
||||||
return EffectAllow
|
|
||||||
}
|
|
||||||
|
|
||||||
type IPBypassedRule struct {
|
|
||||||
Log *logger.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rule *IPBypassedRule) Evaluate(ctx *ACLContext) Effect {
|
|
||||||
if ctx.ACLs == nil {
|
|
||||||
return EffectDeny
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, bypassed := range ctx.ACLs.IP.Bypass {
|
|
||||||
match, err := utils.CheckIPFilter(bypassed, ctx.IP.String())
|
|
||||||
if err != nil {
|
|
||||||
rule.Log.App.Warn().Err(err).Str("item", bypassed).Msg("Invalid IP/CIDR in bypass list")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if match {
|
|
||||||
rule.Log.App.Debug().Str("ip", ctx.IP.String()).Str("item", bypassed).Msg("IP is in bypass list, skipping authentication")
|
|
||||||
return EffectAllow
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rule.Log.App.Debug().Str("ip", ctx.IP.String()).Msg("IP not in bypass list, proceeding with authentication")
|
|
||||||
return EffectDeny
|
|
||||||
}
|
|
||||||
@@ -13,33 +13,32 @@ type LabelProvider interface {
|
|||||||
|
|
||||||
type AccessControlsService struct {
|
type AccessControlsService struct {
|
||||||
log *logger.Logger
|
log *logger.Logger
|
||||||
config model.Config
|
|
||||||
labelProvider *LabelProvider
|
labelProvider *LabelProvider
|
||||||
|
static map[string]model.App
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAccessControlsService(
|
func NewAccessControlsService(
|
||||||
log *logger.Logger,
|
log *logger.Logger,
|
||||||
config model.Config,
|
labelProvider *LabelProvider,
|
||||||
labelProvider *LabelProvider) *AccessControlsService {
|
static map[string]model.App) *AccessControlsService {
|
||||||
|
|
||||||
return &AccessControlsService{
|
return &AccessControlsService{
|
||||||
log: log,
|
log: log,
|
||||||
config: config,
|
|
||||||
labelProvider: labelProvider,
|
labelProvider: labelProvider,
|
||||||
|
static: static,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *AccessControlsService) lookupStaticACLs(domain string) *model.App {
|
func (acls *AccessControlsService) lookupStaticACLs(domain string) *model.App {
|
||||||
var appAcls *model.App
|
var appAcls *model.App
|
||||||
for app, config := range service.config.Apps {
|
for app, config := range acls.static {
|
||||||
if config.Config.Domain == domain {
|
if config.Config.Domain == domain {
|
||||||
service.log.App.Debug().Str("name", app).Msg("Found matching container by domain")
|
acls.log.App.Debug().Str("name", app).Msg("Found matching container by domain")
|
||||||
appAcls = &config
|
appAcls = &config
|
||||||
break // If we find a match by domain, we can stop searching
|
break // If we find a match by domain, we can stop searching
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.SplitN(domain, ".", 2)[0] == app {
|
if strings.SplitN(domain, ".", 2)[0] == app {
|
||||||
service.log.App.Debug().Str("name", app).Msg("Found matching container by app name")
|
acls.log.App.Debug().Str("name", app).Msg("Found matching container by app name")
|
||||||
appAcls = &config
|
appAcls = &config
|
||||||
break // If we find a match by app name, we can stop searching
|
break // If we find a match by app name, we can stop searching
|
||||||
}
|
}
|
||||||
@@ -47,18 +46,18 @@ func (service *AccessControlsService) lookupStaticACLs(domain string) *model.App
|
|||||||
return appAcls
|
return appAcls
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *AccessControlsService) GetAccessControls(domain string) (*model.App, error) {
|
func (acls *AccessControlsService) GetAccessControls(domain string) (*model.App, error) {
|
||||||
// First check in the static config
|
// First check in the static config
|
||||||
app := service.lookupStaticACLs(domain)
|
app := acls.lookupStaticACLs(domain)
|
||||||
|
|
||||||
if app != nil {
|
if app != nil {
|
||||||
service.log.App.Debug().Msg("Using static ACLs for app")
|
acls.log.App.Debug().Msg("Using static ACLs for app")
|
||||||
return app, nil
|
return app, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have a label provider configured, try to get ACLs from it
|
// If we have a label provider configured, try to get ACLs from it
|
||||||
if service.labelProvider != nil {
|
if acls.labelProvider != nil {
|
||||||
return (*service.labelProvider).GetLabels(domain)
|
return (*acls.labelProvider).GetLabels(domain)
|
||||||
}
|
}
|
||||||
|
|
||||||
// no labels
|
// no labels
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -17,6 +18,7 @@ import (
|
|||||||
|
|
||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
@@ -284,12 +286,7 @@ func (auth *AuthService) RecordLoginAttempt(identifier string, success bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (auth *AuthService) IsEmailWhitelisted(email string) bool {
|
func (auth *AuthService) IsEmailWhitelisted(email string) bool {
|
||||||
match, err := utils.CheckFilter(strings.Join(auth.runtime.OAuthWhitelist, ","), email)
|
return utils.CheckFilter(strings.Join(auth.runtime.OAuthWhitelist, ","), email)
|
||||||
if err != nil {
|
|
||||||
auth.log.App.Warn().Err(err).Str("email", email).Msg("Invalid email filter pattern")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return match
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *AuthService) CreateSession(ctx context.Context, data repository.Session) (*http.Cookie, error) {
|
func (auth *AuthService) CreateSession(ctx context.Context, data repository.Session) (*http.Cookie, error) {
|
||||||
@@ -457,6 +454,171 @@ func (auth *AuthService) LDAPAuthConfigured() bool {
|
|||||||
return auth.ldap != nil
|
return auth.ldap != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (auth *AuthService) IsUserAllowed(c *gin.Context, context model.UserContext, acls *model.App) bool {
|
||||||
|
if acls == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if context.Provider == model.ProviderOAuth {
|
||||||
|
auth.log.App.Debug().Msg("User is an OAuth user, checking OAuth whitelist")
|
||||||
|
return utils.CheckFilter(acls.OAuth.Whitelist, context.OAuth.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
if acls.Users.Block != "" {
|
||||||
|
auth.log.App.Debug().Msg("Checking users block list")
|
||||||
|
if utils.CheckFilter(acls.Users.Block, context.GetUsername()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auth.log.App.Debug().Msg("Checking users allow list")
|
||||||
|
return utils.CheckFilter(acls.Users.Allow, context.GetUsername())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (auth *AuthService) IsInOAuthGroup(c *gin.Context, context model.UserContext, acls *model.App) bool {
|
||||||
|
if acls == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !context.IsOAuth() {
|
||||||
|
auth.log.App.Debug().Msg("User is not an OAuth user, skipping OAuth group check")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := model.OverrideProviders[context.OAuth.ID]; ok {
|
||||||
|
auth.log.App.Debug().Str("provider", context.OAuth.ID).Msg("Provider override detected, skipping group check")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, userGroup := range context.OAuth.Groups {
|
||||||
|
if utils.CheckFilter(acls.OAuth.Groups, strings.TrimSpace(userGroup)) {
|
||||||
|
auth.log.App.Trace().Str("group", userGroup).Str("required", acls.OAuth.Groups).Msg("User group matched")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auth.log.App.Debug().Msg("No groups matched")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (auth *AuthService) IsInLDAPGroup(c *gin.Context, context model.UserContext, acls *model.App) bool {
|
||||||
|
if acls == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !context.IsLDAP() {
|
||||||
|
auth.log.App.Debug().Msg("User is not an LDAP user, skipping LDAP group check")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, userGroup := range context.LDAP.Groups {
|
||||||
|
if utils.CheckFilter(acls.LDAP.Groups, strings.TrimSpace(userGroup)) {
|
||||||
|
auth.log.App.Trace().Str("group", userGroup).Str("required", acls.LDAP.Groups).Msg("User group matched")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auth.log.App.Debug().Msg("No groups matched")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (auth *AuthService) IsAuthEnabled(uri string, acls *model.App) (bool, error) {
|
||||||
|
if acls == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for block list
|
||||||
|
if acls.Path.Block != "" {
|
||||||
|
regex, err := regexp.Compile(acls.Path.Block)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !regex.MatchString(uri) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for allow list
|
||||||
|
if acls.Path.Allow != "" {
|
||||||
|
regex, err := regexp.Compile(acls.Path.Allow)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if regex.MatchString(uri) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (auth *AuthService) CheckIP(ip string, acls *model.App) bool {
|
||||||
|
if acls == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge the global and app IP filter
|
||||||
|
blockedIps := append(auth.config.Auth.IP.Block, acls.IP.Block...)
|
||||||
|
allowedIPs := append(auth.config.Auth.IP.Allow, acls.IP.Allow...)
|
||||||
|
|
||||||
|
for _, blocked := range blockedIps {
|
||||||
|
res, err := utils.FilterIP(blocked, ip)
|
||||||
|
if err != nil {
|
||||||
|
auth.log.App.Warn().Err(err).Str("item", blocked).Msg("Invalid IP/CIDR in block list")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if res {
|
||||||
|
auth.log.App.Debug().Str("ip", ip).Str("item", blocked).Msg("IP is in block list, denying access")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, allowed := range allowedIPs {
|
||||||
|
res, err := utils.FilterIP(allowed, ip)
|
||||||
|
if err != nil {
|
||||||
|
auth.log.App.Warn().Err(err).Str("item", allowed).Msg("Invalid IP/CIDR in allow list")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if res {
|
||||||
|
auth.log.App.Debug().Str("ip", ip).Str("item", allowed).Msg("IP is in allow list, allowing access")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allowedIPs) > 0 {
|
||||||
|
auth.log.App.Debug().Str("ip", ip).Msg("IP not in allow list, denying access")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
auth.log.App.Debug().Str("ip", ip).Msg("IP not in any block or allow list, allowing access by default")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (auth *AuthService) IsBypassedIP(ip string, acls *model.App) bool {
|
||||||
|
if acls == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, bypassed := range acls.IP.Bypass {
|
||||||
|
res, err := utils.FilterIP(bypassed, ip)
|
||||||
|
if err != nil {
|
||||||
|
auth.log.App.Warn().Err(err).Str("item", bypassed).Msg("Invalid IP/CIDR in bypass list")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if res {
|
||||||
|
auth.log.App.Debug().Str("ip", ip).Str("item", bypassed).Msg("IP is in bypass list, skipping authentication")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auth.log.App.Debug().Str("ip", ip).Msg("IP not in bypass list, proceeding with authentication")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (auth *AuthService) NewOAuthSession(serviceName string, params OAuthURLParams) (string, OAuthPendingSession, error) {
|
func (auth *AuthService) NewOAuthSession(serviceName string, params OAuthURLParams) (string, OAuthPendingSession, error) {
|
||||||
auth.ensureOAuthSessionLimit()
|
auth.ensureOAuthSessionLimit()
|
||||||
|
|
||||||
@@ -611,49 +773,46 @@ func (auth *AuthService) ensureOAuthSessionLimit() {
|
|||||||
auth.oauthMutex.Lock()
|
auth.oauthMutex.Lock()
|
||||||
defer auth.oauthMutex.Unlock()
|
defer auth.oauthMutex.Unlock()
|
||||||
|
|
||||||
if len(auth.oauthPendingSessions) <= MaxOAuthPendingSessions {
|
if len(auth.oauthPendingSessions) >= MaxOAuthPendingSessions {
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
type entry struct {
|
cleanupIds := make([]string, 0, OAuthCleanupCount)
|
||||||
id string
|
|
||||||
expiresAt int64
|
for range OAuthCleanupCount {
|
||||||
}
|
oldestId := ""
|
||||||
|
oldestTime := int64(0)
|
||||||
|
|
||||||
entries := make([]entry, 0, len(auth.oauthPendingSessions))
|
|
||||||
for id, session := range auth.oauthPendingSessions {
|
for id, session := range auth.oauthPendingSessions {
|
||||||
entries = append(entries, entry{id, session.ExpiresAt.Unix()})
|
if oldestTime == 0 {
|
||||||
|
oldestId = id
|
||||||
|
oldestTime = session.ExpiresAt.Unix()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if slices.Contains(cleanupIds, id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if session.ExpiresAt.Unix() < oldestTime {
|
||||||
|
oldestId = id
|
||||||
|
oldestTime = session.ExpiresAt.Unix()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
slices.SortFunc(entries, func(a, b entry) int {
|
cleanupIds = append(cleanupIds, oldestId)
|
||||||
if a.expiresAt < b.expiresAt {
|
|
||||||
return -1
|
|
||||||
}
|
}
|
||||||
if a.expiresAt > b.expiresAt {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
})
|
|
||||||
|
|
||||||
for _, e := range entries[:OAuthCleanupCount] {
|
for _, id := range cleanupIds {
|
||||||
delete(auth.oauthPendingSessions, e.id)
|
delete(auth.oauthPendingSessions, id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *AuthService) lockdownMode() {
|
func (auth *AuthService) lockdownMode() {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
auth.loginMutex.Lock()
|
|
||||||
|
|
||||||
if auth.lockdown != nil && auth.lockdown.Active {
|
|
||||||
auth.loginMutex.Unlock()
|
|
||||||
cancel()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
auth.lockdownCtx = ctx
|
auth.lockdownCtx = ctx
|
||||||
auth.lockdownCancelFunc = cancel
|
auth.lockdownCancelFunc = cancel
|
||||||
|
|
||||||
|
auth.loginMutex.Lock()
|
||||||
|
|
||||||
auth.log.App.Warn().Msg("Too many failed login attempts, entering lockdown mode")
|
auth.log.App.Warn().Msg("Too many failed login attempts, entering lockdown mode")
|
||||||
|
|
||||||
auth.lockdown = &Lockdown{
|
auth.lockdown = &Lockdown{
|
||||||
@@ -666,12 +825,10 @@ func (auth *AuthService) lockdownMode() {
|
|||||||
auth.loginAttempts = make(map[string]*LoginAttempt)
|
auth.loginAttempts = make(map[string]*LoginAttempt)
|
||||||
|
|
||||||
timer := time.NewTimer(time.Until(auth.lockdown.ActiveUntil))
|
timer := time.NewTimer(time.Until(auth.lockdown.ActiveUntil))
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
auth.loginMutex.Unlock()
|
auth.loginMutex.Unlock()
|
||||||
|
|
||||||
defer cancel()
|
|
||||||
defer timer.Stop()
|
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-timer.C:
|
case <-timer.C:
|
||||||
// Timer expired, end lockdown
|
// Timer expired, end lockdown
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ func NewOAuthService(config model.OAuthServiceConfig, id string, ctx context.Con
|
|||||||
Transport: &http.Transport{
|
Transport: &http.Transport{
|
||||||
TLSClientConfig: &tls.Config{
|
TLSClientConfig: &tls.Config{
|
||||||
InsecureSkipVerify: config.Insecure,
|
InsecureSkipVerify: config.Insecure,
|
||||||
MinVersion: tls.VersionTLS12,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ type OIDCService struct {
|
|||||||
|
|
||||||
clients map[string]model.OIDCClientConfig
|
clients map[string]model.OIDCClientConfig
|
||||||
privateKey *rsa.PrivateKey
|
privateKey *rsa.PrivateKey
|
||||||
publicKey *rsa.PublicKey
|
publicKey crypto.PublicKey
|
||||||
issuer string
|
issuer string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,16 +239,6 @@ func NewOIDCService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rPublicKey, ok := publicKey.(*rsa.PublicKey)
|
|
||||||
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("public key is not an rsa public key")
|
|
||||||
}
|
|
||||||
|
|
||||||
if rPublicKey.N.Cmp(privateKey.N) != 0 || rPublicKey.E != privateKey.E {
|
|
||||||
return nil, fmt.Errorf("public key does not pair with private key")
|
|
||||||
}
|
|
||||||
|
|
||||||
// We will reorganize the client into a map with the client ID as the key
|
// We will reorganize the client into a map with the client ID as the key
|
||||||
clients := make(map[string]model.OIDCClientConfig)
|
clients := make(map[string]model.OIDCClientConfig)
|
||||||
|
|
||||||
@@ -281,7 +271,7 @@ func NewOIDCService(
|
|||||||
|
|
||||||
clients: clients,
|
clients: clients,
|
||||||
privateKey: privateKey,
|
privateKey: privateKey,
|
||||||
publicKey: rPublicKey,
|
publicKey: publicKey,
|
||||||
issuer: issuer,
|
issuer: issuer,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,7 +455,7 @@ func (service *OIDCService) generateIDToken(client model.OIDCClientConfig, user
|
|||||||
|
|
||||||
hasher := sha256.New()
|
hasher := sha256.New()
|
||||||
|
|
||||||
der := x509.MarshalPKCS1PublicKey(service.publicKey)
|
der := x509.MarshalPKCS1PublicKey(&service.privateKey.PublicKey)
|
||||||
|
|
||||||
if der == nil {
|
if der == nil {
|
||||||
return "", errors.New("failed to marshal public key")
|
return "", errors.New("failed to marshal public key")
|
||||||
@@ -823,7 +813,7 @@ func (service *OIDCService) cleanupRoutine() {
|
|||||||
func (service *OIDCService) GetJWK() ([]byte, error) {
|
func (service *OIDCService) GetJWK() ([]byte, error) {
|
||||||
hasher := sha256.New()
|
hasher := sha256.New()
|
||||||
|
|
||||||
der := x509.MarshalPKCS1PublicKey(service.publicKey)
|
der := x509.MarshalPKCS1PublicKey(&service.privateKey.PublicKey)
|
||||||
|
|
||||||
if der == nil {
|
if der == nil {
|
||||||
return nil, errors.New("failed to marshal public key")
|
return nil, errors.New("failed to marshal public key")
|
||||||
@@ -832,13 +822,13 @@ func (service *OIDCService) GetJWK() ([]byte, error) {
|
|||||||
hasher.Write(der)
|
hasher.Write(der)
|
||||||
|
|
||||||
jwk := jose.JSONWebKey{
|
jwk := jose.JSONWebKey{
|
||||||
Key: service.publicKey,
|
Key: service.privateKey,
|
||||||
Algorithm: string(jose.RS256),
|
Algorithm: string(jose.RS256),
|
||||||
Use: "sig",
|
Use: "sig",
|
||||||
KeyID: base64.URLEncoding.EncodeToString(hasher.Sum(nil)),
|
KeyID: base64.URLEncoding.EncodeToString(hasher.Sum(nil)),
|
||||||
}
|
}
|
||||||
|
|
||||||
return jwk.MarshalJSON()
|
return jwk.Public().MarshalJSON()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *OIDCService) ValidatePKCE(codeChallenge string, codeVerifier string) bool {
|
func (service *OIDCService) ValidatePKCE(codeChallenge string, codeVerifier string) bool {
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Policy string
|
|
||||||
|
|
||||||
const (
|
|
||||||
PolicyAllow Policy = "allow"
|
|
||||||
PolicyDeny Policy = "deny"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Effect int
|
|
||||||
|
|
||||||
const (
|
|
||||||
EffectAbstain Effect = iota
|
|
||||||
EffectAllow
|
|
||||||
EffectDeny
|
|
||||||
)
|
|
||||||
|
|
||||||
type Rule interface {
|
|
||||||
Evaluate(ctx *ACLContext) Effect
|
|
||||||
}
|
|
||||||
|
|
||||||
type ACLContext struct {
|
|
||||||
ACLs *model.App
|
|
||||||
UserContext *model.UserContext
|
|
||||||
IP net.IP
|
|
||||||
Path string
|
|
||||||
}
|
|
||||||
|
|
||||||
type PolicyEngine struct {
|
|
||||||
log *logger.Logger
|
|
||||||
rules map[RuleName]Rule
|
|
||||||
policy Policy
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPolicyEngine(config model.Config, log *logger.Logger) (*PolicyEngine, error) {
|
|
||||||
engine := PolicyEngine{
|
|
||||||
log: log,
|
|
||||||
rules: make(map[RuleName]Rule),
|
|
||||||
}
|
|
||||||
|
|
||||||
switch config.Auth.ACLs.Policy {
|
|
||||||
case string(PolicyAllow):
|
|
||||||
log.App.Debug().Msg("Using 'allow' ACL policy: access to apps will be allowed by default unless explicitly blocked")
|
|
||||||
engine.policy = PolicyAllow
|
|
||||||
case string(PolicyDeny):
|
|
||||||
log.App.Debug().Msg("Using 'deny' ACL policy: access to apps will be blocked by default unless explicitly allowed")
|
|
||||||
engine.policy = PolicyDeny
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("invalid acl policy: %s", config.Auth.ACLs.Policy)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &engine, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (engine *PolicyEngine) RegisterRule(name RuleName, rule Rule) {
|
|
||||||
engine.rules[name] = rule
|
|
||||||
}
|
|
||||||
|
|
||||||
func (engine *PolicyEngine) evaluateRuleByName(name RuleName, ctx *ACLContext) Effect {
|
|
||||||
rule, exists := engine.rules[name]
|
|
||||||
|
|
||||||
if !exists {
|
|
||||||
engine.log.App.Warn().Str("rule", string(name)).Msg("Rule not found in policy engine, defaulting to deny")
|
|
||||||
return EffectDeny
|
|
||||||
}
|
|
||||||
|
|
||||||
return rule.Evaluate(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (engine *PolicyEngine) effectToAccess(effect Effect) bool {
|
|
||||||
switch effect {
|
|
||||||
case EffectAllow:
|
|
||||||
return true
|
|
||||||
case EffectDeny:
|
|
||||||
return false
|
|
||||||
default:
|
|
||||||
// If the effect is abstain, we fall back to the default policy
|
|
||||||
return engine.policy == PolicyAllow
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (engine *PolicyEngine) Evaluate(name RuleName, ctx *ACLContext) bool {
|
|
||||||
effect := engine.evaluateRuleByName(name, ctx)
|
|
||||||
access := engine.effectToAccess(effect)
|
|
||||||
|
|
||||||
engine.log.App.Debug().
|
|
||||||
Str("rule", string(name)).
|
|
||||||
Int("effect", int(effect)).
|
|
||||||
Bool("access", access).
|
|
||||||
Msg("Evaluated ACL rule")
|
|
||||||
|
|
||||||
return access
|
|
||||||
}
|
|
||||||
@@ -40,9 +40,6 @@ func CreateTestConfigs(t *testing.T) (model.Config, model.RuntimeConfig) {
|
|||||||
SessionExpiry: 10,
|
SessionExpiry: 10,
|
||||||
LoginTimeout: 10,
|
LoginTimeout: 10,
|
||||||
LoginMaxRetries: 3,
|
LoginMaxRetries: 3,
|
||||||
ACLs: model.ACLsConfig{
|
|
||||||
Policy: "allow",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
Database: model.DatabaseConfig{
|
Database: model.DatabaseConfig{
|
||||||
Path: filepath.Join(tempDir, "test.db"),
|
Path: filepath.Join(tempDir, "test.db"),
|
||||||
@@ -51,32 +48,6 @@ func CreateTestConfigs(t *testing.T) (model.Config, model.RuntimeConfig) {
|
|||||||
Enabled: true,
|
Enabled: true,
|
||||||
Path: filepath.Join(tempDir, "resources"),
|
Path: filepath.Join(tempDir, "resources"),
|
||||||
},
|
},
|
||||||
Apps: map[string]model.App{
|
|
||||||
"app_path_allow": {
|
|
||||||
Config: model.AppConfig{
|
|
||||||
Domain: "path-allow.example.com",
|
|
||||||
},
|
|
||||||
Path: model.AppPath{
|
|
||||||
Allow: "/allowed",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"app_user_allow": {
|
|
||||||
Config: model.AppConfig{
|
|
||||||
Domain: "user-allow.example.com",
|
|
||||||
},
|
|
||||||
Users: model.AppUsers{
|
|
||||||
Allow: "testuser",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"ip_bypass": {
|
|
||||||
Config: model.AppConfig{
|
|
||||||
Domain: "ip-bypass.example.com",
|
|
||||||
},
|
|
||||||
IP: model.AppIP{
|
|
||||||
Bypass: []string{"10.10.10.10"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
passwd, err := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)
|
passwd, err := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package utils
|
|||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -46,27 +46,26 @@ func EncodeBasicAuth(username string, password string) string {
|
|||||||
return base64.StdEncoding.EncodeToString([]byte(auth))
|
return base64.StdEncoding.EncodeToString([]byte(auth))
|
||||||
}
|
}
|
||||||
|
|
||||||
func CheckIPFilter(filter string, ip string) (bool, error) {
|
func FilterIP(filter string, ip string) (bool, error) {
|
||||||
ipAddr := net.ParseIP(ip)
|
ipAddr := net.ParseIP(ip)
|
||||||
|
|
||||||
if ipAddr == nil {
|
if ipAddr == nil {
|
||||||
return false, fmt.Errorf("invalid ip address")
|
return false, errors.New("invalid IP address")
|
||||||
}
|
}
|
||||||
|
|
||||||
filter = strings.ReplaceAll(filter, "-", "/")
|
filter = strings.Replace(filter, "-", "/", -1)
|
||||||
|
|
||||||
if strings.Contains(filter, "/") {
|
if strings.Contains(filter, "/") {
|
||||||
_, cidr, err := net.ParseCIDR(filter)
|
_, cidr, err := net.ParseCIDR(filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("invalid cidr notation: %w", err)
|
return false, err
|
||||||
}
|
}
|
||||||
return cidr.Contains(ipAddr), nil
|
return cidr.Contains(ipAddr), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
ipFilter := net.ParseIP(filter)
|
ipFilter := net.ParseIP(filter)
|
||||||
|
|
||||||
if ipFilter == nil {
|
if ipFilter == nil {
|
||||||
return false, fmt.Errorf("invalid ip address")
|
return false, errors.New("invalid IP address in filter")
|
||||||
}
|
}
|
||||||
|
|
||||||
if ipFilter.Equal(ipAddr) {
|
if ipFilter.Equal(ipAddr) {
|
||||||
@@ -76,29 +75,31 @@ func CheckIPFilter(filter string, ip string) (bool, error) {
|
|||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func CheckFilter(filter string, input string) (bool, error) {
|
func CheckFilter(filter string, str string) bool {
|
||||||
if len(strings.TrimSpace(filter)) == 0 {
|
if len(strings.TrimSpace(filter)) == 0 {
|
||||||
return false, fmt.Errorf("filter is empty")
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(filter, "/") && strings.HasSuffix(filter, "/") {
|
if strings.HasPrefix(filter, "/") && strings.HasSuffix(filter, "/") {
|
||||||
re, err := regexp.Compile(filter[1 : len(filter)-1])
|
re, err := regexp.Compile(filter[1 : len(filter)-1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("invalid regex filter: %w", err)
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if re.MatchString(input) {
|
if re.MatchString(strings.TrimSpace(str)) {
|
||||||
return true, nil
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for item := range strings.SplitSeq(filter, ",") {
|
filterSplit := strings.Split(filter, ",")
|
||||||
if strings.TrimSpace(item) == input {
|
|
||||||
return true, nil
|
for _, item := range filterSplit {
|
||||||
|
if strings.TrimSpace(item) == strings.TrimSpace(str) {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false, nil
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenerateUUID(str string) string {
|
func GenerateUUID(str string) string {
|
||||||
|
|||||||
@@ -75,77 +75,66 @@ func TestEncodeBasicAuth(t *testing.T) {
|
|||||||
assert.Equal(t, expected, utils.EncodeBasicAuth(username, password))
|
assert.Equal(t, expected, utils.EncodeBasicAuth(username, password))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckIPFilter(t *testing.T) {
|
func TestFilterIP(t *testing.T) {
|
||||||
// Exact match IPv4
|
// Exact match IPv4
|
||||||
ok, err := utils.CheckIPFilter("10.10.0.1", "10.10.0.1")
|
ok, err := utils.FilterIP("10.10.0.1", "10.10.0.1")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, true, ok)
|
assert.Equal(t, true, ok)
|
||||||
|
|
||||||
// Non-match IPv4
|
// Non-match IPv4
|
||||||
ok, err = utils.CheckIPFilter("10.10.0.1", "10.10.0.2")
|
ok, err = utils.FilterIP("10.10.0.1", "10.10.0.2")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, false, ok)
|
assert.Equal(t, false, ok)
|
||||||
|
|
||||||
// CIDR match IPv4
|
// CIDR match IPv4
|
||||||
ok, err = utils.CheckIPFilter("10.10.0.0/24", "10.10.0.2")
|
ok, err = utils.FilterIP("10.10.0.0/24", "10.10.0.2")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, true, ok)
|
assert.Equal(t, true, ok)
|
||||||
|
|
||||||
// CIDR match IPv4 with '-' instead of '/'
|
// CIDR match IPv4 with '-' instead of '/'
|
||||||
ok, err = utils.CheckIPFilter("10.10.10.0-24", "10.10.10.5")
|
ok, err = utils.FilterIP("10.10.10.0-24", "10.10.10.5")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, true, ok)
|
assert.Equal(t, true, ok)
|
||||||
|
|
||||||
// CIDR non-match IPv4
|
// CIDR non-match IPv4
|
||||||
ok, err = utils.CheckIPFilter("10.10.0.0/24", "10.5.0.1")
|
ok, err = utils.FilterIP("10.10.0.0/24", "10.5.0.1")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, false, ok)
|
assert.Equal(t, false, ok)
|
||||||
|
|
||||||
// Invalid CIDR
|
// Invalid CIDR
|
||||||
ok, err = utils.CheckIPFilter("10.10.0.0/222", "10.0.0.1")
|
ok, err = utils.FilterIP("10.10.0.0/222", "10.0.0.1")
|
||||||
assert.ErrorContains(t, err, "invalid cidr notation: invalid CIDR address: 10.10.0.0/222")
|
assert.ErrorContains(t, err, "invalid CIDR address")
|
||||||
assert.Equal(t, false, ok)
|
assert.Equal(t, false, ok)
|
||||||
|
|
||||||
// Invalid IP in filter
|
// Invalid IP in filter
|
||||||
ok, err = utils.CheckIPFilter("invalid_ip", "10.5.5.5")
|
ok, err = utils.FilterIP("invalid_ip", "10.5.5.5")
|
||||||
assert.ErrorContains(t, err, "invalid ip address")
|
assert.ErrorContains(t, err, "invalid IP address in filter")
|
||||||
assert.Equal(t, false, ok)
|
assert.Equal(t, false, ok)
|
||||||
|
|
||||||
// Invalid IP to check
|
// Invalid IP to check
|
||||||
ok, err = utils.CheckIPFilter("10.10.10.10", "invalid_ip")
|
ok, err = utils.FilterIP("10.10.10.10", "invalid_ip")
|
||||||
assert.ErrorContains(t, err, "invalid ip address")
|
assert.ErrorContains(t, err, "invalid IP address")
|
||||||
assert.Equal(t, false, ok)
|
assert.Equal(t, false, ok)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckFilter(t *testing.T) {
|
func TestCheckFilter(t *testing.T) {
|
||||||
// Empty filter
|
// Empty filter
|
||||||
_, err := utils.CheckFilter("", "anystring")
|
assert.Equal(t, true, utils.CheckFilter("", "anystring"))
|
||||||
assert.ErrorContains(t, err, "filter is empty")
|
|
||||||
|
|
||||||
// Exact match
|
// Exact match
|
||||||
ok, err := utils.CheckFilter("hello", "hello")
|
assert.Equal(t, true, utils.CheckFilter("hello", "hello"))
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, true, ok)
|
|
||||||
|
|
||||||
// Regex match
|
// Regex match
|
||||||
ok, err = utils.CheckFilter("/^h.*o$/", "hello")
|
assert.Equal(t, true, utils.CheckFilter("/^h.*o$/", "hello"))
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, true, ok)
|
|
||||||
|
|
||||||
// Invalid regex
|
// Invalid regex
|
||||||
ok, err = utils.CheckFilter("/[unclosed/", "test")
|
assert.Equal(t, false, utils.CheckFilter("/[unclosed", "test"))
|
||||||
assert.ErrorContains(t, err, "invalid regex")
|
|
||||||
assert.Equal(t, false, ok)
|
|
||||||
|
|
||||||
// Comma-separated values
|
// Comma-separated values
|
||||||
ok, err = utils.CheckFilter("apple, banana, cherry", "banana")
|
assert.Equal(t, true, utils.CheckFilter("apple, banana, cherry", "banana"))
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, true, ok)
|
|
||||||
|
|
||||||
// No match
|
// No match
|
||||||
ok, err = utils.CheckFilter("apple, banana, cherry", "grape")
|
assert.Equal(t, false, utils.CheckFilter("apple, banana, cherry", "grape"))
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, false, ok)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateUUID(t *testing.T) {
|
func TestGenerateUUID(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user