mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2026-05-09 13:58:11 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67dc85db9f | |||
| 54d6f4dba0 |
@@ -91,8 +91,6 @@ TINYAUTH_APPS_name_LDAP_GROUPS=
|
|||||||
|
|
||||||
# Comma-separated list of allowed OAuth domains.
|
# Comma-separated list of allowed OAuth domains.
|
||||||
TINYAUTH_OAUTH_WHITELIST=
|
TINYAUTH_OAUTH_WHITELIST=
|
||||||
# Path to the OAuth whitelist file.
|
|
||||||
TINYAUTH_OAUTH_WHITELISTFILE=
|
|
||||||
# The OAuth provider to use for automatic redirection.
|
# The OAuth provider to use for automatic redirection.
|
||||||
TINYAUTH_OAUTH_AUTOREDIRECT=
|
TINYAUTH_OAUTH_AUTOREDIRECT=
|
||||||
# OAuth client ID.
|
# OAuth client ID.
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ name: Bug report
|
|||||||
about: Create a report to help improve Tinyauth
|
about: Create a report to help improve Tinyauth
|
||||||
title: "[BUG]"
|
title: "[BUG]"
|
||||||
labels: bug
|
labels: bug
|
||||||
assignees:
|
assignees: steveiliop56
|
||||||
- steveiliop56
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ name: Feature request
|
|||||||
about: Suggest an idea for this project
|
about: Suggest an idea for this project
|
||||||
title: "[FEATURE]"
|
title: "[FEATURE]"
|
||||||
labels: enhancement
|
labels: enhancement
|
||||||
assignees:
|
assignees: steveiliop56
|
||||||
- steveiliop56
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -5,21 +5,18 @@ on:
|
|||||||
- main
|
- main
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
ci:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup bun
|
- name: Setup bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
- name: Setup go
|
- name: Setup go
|
||||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: "^1.26.0"
|
go-version: "^1.26.0"
|
||||||
|
|
||||||
@@ -53,6 +50,6 @@ jobs:
|
|||||||
run: go test -coverprofile=coverage.txt -v ./...
|
run: go test -coverprofile=coverage.txt -v ./...
|
||||||
|
|
||||||
- name: Upload coverage reports to Codecov
|
- name: Upload coverage reports to Codecov
|
||||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6
|
uses: codecov/codecov-action@v6
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|||||||
@@ -4,16 +4,12 @@ on:
|
|||||||
schedule:
|
schedule:
|
||||||
- cron: "0 0 * * *"
|
- cron: "0 0 * * *"
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
create-release:
|
create-release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Delete old release
|
- name: Delete old release
|
||||||
run: gh release delete --cleanup-tag --yes nightly || echo release not found
|
run: gh release delete --cleanup-tag --yes nightly || echo release not found
|
||||||
@@ -23,7 +19,7 @@ jobs:
|
|||||||
REPO: ${{ github.event.repository.name }}
|
REPO: ${{ github.event.repository.name }}
|
||||||
|
|
||||||
- name: Create release
|
- name: Create release
|
||||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
prerelease: true
|
prerelease: true
|
||||||
tag_name: nightly
|
tag_name: nightly
|
||||||
@@ -37,7 +33,7 @@ jobs:
|
|||||||
BUILD_TIMESTAMP: ${{ steps.metadata.outputs.BUILD_TIMESTAMP }}
|
BUILD_TIMESTAMP: ${{ steps.metadata.outputs.BUILD_TIMESTAMP }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: nightly
|
ref: nightly
|
||||||
|
|
||||||
@@ -55,15 +51,15 @@ jobs:
|
|||||||
- generate-metadata
|
- generate-metadata
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: nightly
|
ref: nightly
|
||||||
|
|
||||||
- name: Install bun
|
- name: Install bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
- name: Install go
|
- name: Install go
|
||||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: "^1.26.0"
|
go-version: "^1.26.0"
|
||||||
|
|
||||||
@@ -84,12 +80,12 @@ jobs:
|
|||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
cp -r frontend/dist internal/assets/dist
|
cp -r frontend/dist internal/assets/dist
|
||||||
go build -ldflags "-s -w -X github.com/tinyauthapp/tinyauth/internal/model.Version=${{ needs.generate-metadata.outputs.VERSION }} -X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-amd64 ./cmd/tinyauth
|
go build -ldflags "-s -w -X github.com/steveiliop56/tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X github.com/steveiliop56/tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X github.com/steveiliop56/tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-amd64 ./cmd/tinyauth
|
||||||
env:
|
env:
|
||||||
CGO_ENABLED: 0
|
CGO_ENABLED: 0
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: tinyauth-amd64
|
name: tinyauth-amd64
|
||||||
path: tinyauth-amd64
|
path: tinyauth-amd64
|
||||||
@@ -101,15 +97,15 @@ jobs:
|
|||||||
- generate-metadata
|
- generate-metadata
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: nightly
|
ref: nightly
|
||||||
|
|
||||||
- name: Install bun
|
- name: Install bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
- name: Install go
|
- name: Install go
|
||||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: "^1.26.0"
|
go-version: "^1.26.0"
|
||||||
|
|
||||||
@@ -130,12 +126,12 @@ jobs:
|
|||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
cp -r frontend/dist internal/assets/dist
|
cp -r frontend/dist internal/assets/dist
|
||||||
go build -ldflags "-s -w -X github.com/tinyauthapp/tinyauth/internal/model.Version=${{ needs.generate-metadata.outputs.VERSION }} -X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-arm64 ./cmd/tinyauth
|
go build -ldflags "-s -w -X github.com/steveiliop56/tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X github.com/steveiliop56/tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X github.com/steveiliop56/tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-arm64 ./cmd/tinyauth
|
||||||
env:
|
env:
|
||||||
CGO_ENABLED: 0
|
CGO_ENABLED: 0
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: tinyauth-arm64
|
name: tinyauth-arm64
|
||||||
path: tinyauth-arm64
|
path: tinyauth-arm64
|
||||||
@@ -147,28 +143,28 @@ jobs:
|
|||||||
- generate-metadata
|
- generate-metadata
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: nightly
|
ref: nightly
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
|
uses: docker/build-push-action@v7
|
||||||
id: build
|
id: build
|
||||||
with:
|
with:
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
@@ -190,7 +186,7 @@ jobs:
|
|||||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||||
|
|
||||||
- name: Upload digest
|
- name: Upload digest
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: digests-linux-amd64
|
name: digests-linux-amd64
|
||||||
path: ${{ runner.temp }}/digests/*
|
path: ${{ runner.temp }}/digests/*
|
||||||
@@ -205,28 +201,28 @@ jobs:
|
|||||||
- image-build
|
- image-build
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: nightly
|
ref: nightly
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
|
uses: docker/build-push-action@v7
|
||||||
id: build
|
id: build
|
||||||
with:
|
with:
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
@@ -249,7 +245,7 @@ jobs:
|
|||||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||||
|
|
||||||
- name: Upload digest
|
- name: Upload digest
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: digests-distroless-linux-amd64
|
name: digests-distroless-linux-amd64
|
||||||
path: ${{ runner.temp }}/digests/*
|
path: ${{ runner.temp }}/digests/*
|
||||||
@@ -263,28 +259,28 @@ jobs:
|
|||||||
- generate-metadata
|
- generate-metadata
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: nightly
|
ref: nightly
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
|
uses: docker/build-push-action@v7
|
||||||
id: build
|
id: build
|
||||||
with:
|
with:
|
||||||
platforms: linux/arm64
|
platforms: linux/arm64
|
||||||
@@ -306,7 +302,7 @@ jobs:
|
|||||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||||
|
|
||||||
- name: Upload digest
|
- name: Upload digest
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: digests-linux-arm64
|
name: digests-linux-arm64
|
||||||
path: ${{ runner.temp }}/digests/*
|
path: ${{ runner.temp }}/digests/*
|
||||||
@@ -321,28 +317,28 @@ jobs:
|
|||||||
- image-build-arm
|
- image-build-arm
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: nightly
|
ref: nightly
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
|
uses: docker/build-push-action@v7
|
||||||
id: build
|
id: build
|
||||||
with:
|
with:
|
||||||
platforms: linux/arm64
|
platforms: linux/arm64
|
||||||
@@ -365,7 +361,7 @@ jobs:
|
|||||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||||
|
|
||||||
- name: Upload digest
|
- name: Upload digest
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: digests-distroless-linux-arm64
|
name: digests-distroless-linux-arm64
|
||||||
path: ${{ runner.temp }}/digests/*
|
path: ${{ runner.temp }}/digests/*
|
||||||
@@ -379,25 +375,25 @@ jobs:
|
|||||||
- image-build-arm
|
- image-build-arm
|
||||||
steps:
|
steps:
|
||||||
- name: Download digests
|
- name: Download digests
|
||||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
path: ${{ runner.temp }}/digests
|
path: ${{ runner.temp }}/digests
|
||||||
pattern: digests-*
|
pattern: digests-*
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
||||||
flavor: |
|
flavor: |
|
||||||
@@ -418,25 +414,25 @@ jobs:
|
|||||||
- image-build-arm-distroless
|
- image-build-arm-distroless
|
||||||
steps:
|
steps:
|
||||||
- name: Download digests
|
- name: Download digests
|
||||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
path: ${{ runner.temp }}/digests
|
path: ${{ runner.temp }}/digests
|
||||||
pattern: digests-distroless-*
|
pattern: digests-distroless-*
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
||||||
flavor: |
|
flavor: |
|
||||||
@@ -456,14 +452,14 @@ jobs:
|
|||||||
- binary-build
|
- binary-build
|
||||||
- binary-build-arm
|
- binary-build-arm
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
- uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
pattern: tinyauth-*
|
pattern: tinyauth-*
|
||||||
path: binaries
|
path: binaries
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
files: binaries/*
|
files: binaries/*
|
||||||
tag_name: nightly
|
tag_name: nightly
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ on:
|
|||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
generate-metadata:
|
generate-metadata:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -18,7 +14,7 @@ jobs:
|
|||||||
BUILD_TIMESTAMP: ${{ steps.metadata.outputs.BUILD_TIMESTAMP }}
|
BUILD_TIMESTAMP: ${{ steps.metadata.outputs.BUILD_TIMESTAMP }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Generate metadata
|
- name: Generate metadata
|
||||||
id: metadata
|
id: metadata
|
||||||
@@ -33,13 +29,13 @@ jobs:
|
|||||||
- generate-metadata
|
- generate-metadata
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install bun
|
- name: Install bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
- name: Install go
|
- name: Install go
|
||||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: "^1.26.0"
|
go-version: "^1.26.0"
|
||||||
|
|
||||||
@@ -60,12 +56,12 @@ jobs:
|
|||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
cp -r frontend/dist internal/assets/dist
|
cp -r frontend/dist internal/assets/dist
|
||||||
go build -ldflags "-s -w -X github.com/tinyauthapp/tinyauth/internal/model.Version=${{ needs.generate-metadata.outputs.VERSION }} -X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-amd64 ./cmd/tinyauth
|
go build -ldflags "-s -w -X github.com/steveiliop56/tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X github.com/steveiliop56/tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X github.com/steveiliop56/tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-amd64 ./cmd/tinyauth
|
||||||
env:
|
env:
|
||||||
CGO_ENABLED: 0
|
CGO_ENABLED: 0
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: tinyauth-amd64
|
name: tinyauth-amd64
|
||||||
path: tinyauth-amd64
|
path: tinyauth-amd64
|
||||||
@@ -76,13 +72,13 @@ jobs:
|
|||||||
- generate-metadata
|
- generate-metadata
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install bun
|
- name: Install bun
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
- name: Install go
|
- name: Install go
|
||||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: "^1.26.0"
|
go-version: "^1.26.0"
|
||||||
|
|
||||||
@@ -103,12 +99,12 @@ jobs:
|
|||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
cp -r frontend/dist internal/assets/dist
|
cp -r frontend/dist internal/assets/dist
|
||||||
go build -ldflags "-s -w -X github.com/tinyauthapp/tinyauth/internal/model.Version=${{ needs.generate-metadata.outputs.VERSION }} -X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-arm64 ./cmd/tinyauth
|
go build -ldflags "-s -w -X github.com/steveiliop56/tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X github.com/steveiliop56/tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X github.com/steveiliop56/tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-arm64 ./cmd/tinyauth
|
||||||
env:
|
env:
|
||||||
CGO_ENABLED: 0
|
CGO_ENABLED: 0
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: tinyauth-arm64
|
name: tinyauth-arm64
|
||||||
path: tinyauth-arm64
|
path: tinyauth-arm64
|
||||||
@@ -119,26 +115,26 @@ jobs:
|
|||||||
- generate-metadata
|
- generate-metadata
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
|
uses: docker/build-push-action@v7
|
||||||
id: build
|
id: build
|
||||||
with:
|
with:
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
@@ -160,7 +156,7 @@ jobs:
|
|||||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||||
|
|
||||||
- name: Upload digest
|
- name: Upload digest
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: digests-linux-amd64
|
name: digests-linux-amd64
|
||||||
path: ${{ runner.temp }}/digests/*
|
path: ${{ runner.temp }}/digests/*
|
||||||
@@ -174,26 +170,26 @@ jobs:
|
|||||||
- image-build
|
- image-build
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
|
uses: docker/build-push-action@v7
|
||||||
id: build
|
id: build
|
||||||
with:
|
with:
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
@@ -216,7 +212,7 @@ jobs:
|
|||||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||||
|
|
||||||
- name: Upload digest
|
- name: Upload digest
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: digests-distroless-linux-amd64
|
name: digests-distroless-linux-amd64
|
||||||
path: ${{ runner.temp }}/digests/*
|
path: ${{ runner.temp }}/digests/*
|
||||||
@@ -229,26 +225,26 @@ jobs:
|
|||||||
- generate-metadata
|
- generate-metadata
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
|
uses: docker/build-push-action@v7
|
||||||
id: build
|
id: build
|
||||||
with:
|
with:
|
||||||
platforms: linux/arm64
|
platforms: linux/arm64
|
||||||
@@ -270,7 +266,7 @@ jobs:
|
|||||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||||
|
|
||||||
- name: Upload digest
|
- name: Upload digest
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: digests-linux-arm64
|
name: digests-linux-arm64
|
||||||
path: ${{ runner.temp }}/digests/*
|
path: ${{ runner.temp }}/digests/*
|
||||||
@@ -284,26 +280,26 @@ jobs:
|
|||||||
- image-build-arm
|
- image-build-arm
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
|
uses: docker/build-push-action@v7
|
||||||
id: build
|
id: build
|
||||||
with:
|
with:
|
||||||
platforms: linux/arm64
|
platforms: linux/arm64
|
||||||
@@ -326,7 +322,7 @@ jobs:
|
|||||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||||
|
|
||||||
- name: Upload digest
|
- name: Upload digest
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: digests-distroless-linux-arm64
|
name: digests-distroless-linux-arm64
|
||||||
path: ${{ runner.temp }}/digests/*
|
path: ${{ runner.temp }}/digests/*
|
||||||
@@ -340,25 +336,25 @@ jobs:
|
|||||||
- image-build-arm
|
- image-build-arm
|
||||||
steps:
|
steps:
|
||||||
- name: Download digests
|
- name: Download digests
|
||||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
path: ${{ runner.temp }}/digests
|
path: ${{ runner.temp }}/digests
|
||||||
pattern: digests-*
|
pattern: digests-*
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
||||||
flavor: |
|
flavor: |
|
||||||
@@ -381,25 +377,25 @@ jobs:
|
|||||||
- image-build-arm-distroless
|
- image-build-arm-distroless
|
||||||
steps:
|
steps:
|
||||||
- name: Download digests
|
- name: Download digests
|
||||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
path: ${{ runner.temp }}/digests
|
path: ${{ runner.temp }}/digests
|
||||||
pattern: digests-distroless-*
|
pattern: digests-distroless-*
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
||||||
flavor: |
|
flavor: |
|
||||||
@@ -423,13 +419,13 @@ jobs:
|
|||||||
- binary-build
|
- binary-build
|
||||||
- binary-build-arm
|
- binary-build-arm
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
- uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
pattern: tinyauth-*
|
pattern: tinyauth-*
|
||||||
path: binaries
|
path: binaries
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
files: binaries/*
|
files: binaries/*
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
name: Scorecard supply-chain security
|
|
||||||
on:
|
|
||||||
branch_protection_rule:
|
|
||||||
schedule:
|
|
||||||
- cron: "31 17 * * 5"
|
|
||||||
push:
|
|
||||||
branches: ["main"]
|
|
||||||
|
|
||||||
permissions: read-all
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
analysis:
|
|
||||||
name: Scorecard analysis
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: github.event.repository.default_branch == github.ref_name || github.event_name == 'pull_request'
|
|
||||||
permissions:
|
|
||||||
security-events: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Run analysis
|
|
||||||
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a
|
|
||||||
with:
|
|
||||||
results_file: results.sarif
|
|
||||||
results_format: sarif
|
|
||||||
publish_results: true
|
|
||||||
|
|
||||||
- name: Upload artifact
|
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
|
||||||
with:
|
|
||||||
name: SARIF file
|
|
||||||
path: results.sarif
|
|
||||||
retention-days: 5
|
|
||||||
|
|
||||||
- name: Upload to code-scanning
|
|
||||||
uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4
|
|
||||||
with:
|
|
||||||
sarif_file: results.sarif
|
|
||||||
@@ -2,19 +2,15 @@ name: Generate Sponsors List
|
|||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
generate-sponsors:
|
generate-sponsors:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Generate Sponsors
|
- name: Generate Sponsors
|
||||||
uses: JamesIves/github-sponsors-readme-action@2fd9142e765f755780202122261dc85e78459405 # v1
|
uses: JamesIves/github-sponsors-readme-action@v1
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.SPONSORS_GENERATOR_PAT }}
|
token: ${{ secrets.SPONSORS_GENERATOR_PAT }}
|
||||||
active-only: false
|
active-only: false
|
||||||
@@ -22,7 +18,7 @@ jobs:
|
|||||||
template: '<a href="https://github.com/{{{ login }}}"><img src="{{{ avatarUrl }}}" width="64px" alt="User avatar: {{{ login }}}" /></a> '
|
template: '<a href="https://github.com/{{{ login }}}"><img src="{{{ avatarUrl }}}" width="64px" alt="User avatar: {{{ login }}}" /></a> '
|
||||||
|
|
||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8
|
uses: peter-evans/create-pull-request@v8
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
commit-message: |
|
commit-message: |
|
||||||
|
|||||||
@@ -3,15 +3,11 @@ on:
|
|||||||
schedule:
|
schedule:
|
||||||
- cron: 0 10 * * *
|
- cron: 0 10 * * *
|
||||||
|
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
stale:
|
stale:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10
|
- uses: actions/stale@v10
|
||||||
with:
|
with:
|
||||||
days-before-stale: 30
|
days-before-stale: 30
|
||||||
stale-pr-message: This PR has been inactive for 30 days and will be marked as stale.
|
stale-pr-message: This PR has been inactive for 30 days and will be marked as stale.
|
||||||
|
|||||||
Vendored
+15
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Connect to server",
|
||||||
|
"type": "go",
|
||||||
|
"request": "attach",
|
||||||
|
"mode": "remote",
|
||||||
|
"remotePath": "/tinyauth",
|
||||||
|
"port": 4000,
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"debugAdapter": "legacy"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# AI Usage Policy
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> By Tinyauth, we refer to the entire Tinyauth ([tinyauthapp](https://github.com/tinyauthapp)) organization and all of the repositories under it.
|
|
||||||
|
|
||||||
## How we utilize AI in Tinyauth
|
|
||||||
|
|
||||||
In Tinyauth, we see AI as another tool designed to help developers accelerate their work, ***not*** as something that should be doing the development for them. The ways we utilize large language models in Tinyauth are the following:
|
|
||||||
|
|
||||||
- **Pull request reviews**: We utilize [CodeRabbit](https://www.coderabbit.ai/) for reviews in our pull requests which helps us find and fix issues faster, minimizing the time maintainers have to spend reviewing.
|
|
||||||
- **Documentation and Issues**: We use [Dosu](https://dosu.dev/) to help resolve duplicate issues faster and automatically update our documentation based on changes in the code base.
|
|
||||||
- **In-Line Suggestions**: GitHub's [Copilot](https://github.com/features/copilot) is partially used to fill in boilerplate code through in-line suggestions.
|
|
||||||
|
|
||||||
## How we expect the community to use AI
|
|
||||||
|
|
||||||
We expect the Tinyauth community to use AI as a tool for faster development and not as a way to implement entire features through prompts. For this reason, the following guidelines are in place for AI generated content:
|
|
||||||
|
|
||||||
- **All usage must be clearly labeled**: Any content generated by AI must be clearly labeled as such. In the case that a pull request is clearly generated by AI and the author fails to disclose its use, it will be rejected.
|
|
||||||
- **All generated content should be completely understood by the account holder**: The human who utilized the large language model to generate content must have a thorough understanding of it. This includes understanding the resulting output to the full extent and being able to explain it in detail in case it's needed.
|
|
||||||
- **Automated systems are not allowed**: All forms of automated systems that utilize large language models to generate content without human oversight are forbidden. This includes any system that generates content without a human being directly involved in the process like for example with OpenClaw.
|
|
||||||
- **No generated content other than text is allowed**: Images, videos, audio and any other form of content generated by AI other than text is not allowed in Tinyauth.
|
|
||||||
- **AI pull requests are not guaranteed to be accepted or prioritized**: Any pull request that contains AI generated content is not guaranteed to be accepted and/or prioritized. The maintainers are responsible for reviewing all pull requests and determining whether or not they meet the standards of the project. AI generated content will be reviewed with the same standards as any other content, and may be rejected if it does not meet those standards.
|
|
||||||
- **Large generated pull requests will be rejected**: Any pull request that contains a large amount of generated content will be rejected. This is because it is difficult for the maintainers to review and verify large amounts of generated content.
|
|
||||||
|
|
||||||
## Tinyauth is developed by humans, for humans
|
|
||||||
|
|
||||||
Please remember that Tinyauth is developed by humans. While AI can be a useful tool for **assisting** in the development process, it should not be used in place of the human brain. Moving forward, we are committed to ensuring that most, if not all the content in Tinyauth is created and reviewed by humans, and that AI is only used as a tool to assist in the development process.
|
|
||||||
+1
-4
@@ -2,9 +2,6 @@
|
|||||||
|
|
||||||
Contributing to Tinyauth is straightforward. Follow the steps below to set up a development server.
|
Contributing to Tinyauth is straightforward. Follow the steps below to set up a development server.
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> If you are using large language models to contribute to the project, please ensure that you have read and understood the [AI Policy](AI_POLICY.md).
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Bun
|
- Bun
|
||||||
@@ -18,7 +15,7 @@ Contributing to Tinyauth is straightforward. Follow the steps below to set up a
|
|||||||
Start by cloning the repository:
|
Start by cloning the repository:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://github.com/tinyauthapp/tinyauth
|
git clone https://github.com/steveiliop56/tinyauth
|
||||||
cd tinyauth
|
cd tinyauth
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -1,5 +1,5 @@
|
|||||||
# Site builder
|
# Site builder
|
||||||
FROM oven/bun:1.3.13-alpine AS frontend-builder
|
FROM oven/bun:1.3.12-alpine AS frontend-builder
|
||||||
|
|
||||||
WORKDIR /frontend
|
WORKDIR /frontend
|
||||||
|
|
||||||
@@ -38,9 +38,9 @@ COPY ./internal ./internal
|
|||||||
COPY --from=frontend-builder /frontend/dist ./internal/assets/dist
|
COPY --from=frontend-builder /frontend/dist ./internal/assets/dist
|
||||||
|
|
||||||
RUN CGO_ENABLED=0 go build -ldflags "-s -w \
|
RUN CGO_ENABLED=0 go build -ldflags "-s -w \
|
||||||
-X github.com/tinyauthapp/tinyauth/internal/model.Version=${VERSION} \
|
-X github.com/steveiliop56/tinyauth/internal/config.Version=${VERSION} \
|
||||||
-X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${COMMIT_HASH} \
|
-X github.com/steveiliop56/tinyauth/internal/config.CommitHash=${COMMIT_HASH} \
|
||||||
-X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${BUILD_TIMESTAMP}" ./cmd/tinyauth
|
-X github.com/steveiliop56/tinyauth/internal/config.BuildTimestamp=${BUILD_TIMESTAMP}" ./cmd/tinyauth
|
||||||
|
|
||||||
# Runner
|
# Runner
|
||||||
FROM alpine:3.23 AS runner
|
FROM alpine:3.23 AS runner
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Site builder
|
# Site builder
|
||||||
FROM oven/bun:1.3.13-alpine AS frontend-builder
|
FROM oven/bun:1.3.12-alpine AS frontend-builder
|
||||||
|
|
||||||
WORKDIR /frontend
|
WORKDIR /frontend
|
||||||
|
|
||||||
@@ -40,9 +40,9 @@ COPY --from=frontend-builder /frontend/dist ./internal/assets/dist
|
|||||||
RUN mkdir -p data
|
RUN mkdir -p data
|
||||||
|
|
||||||
RUN CGO_ENABLED=0 go build -ldflags "-s -w \
|
RUN CGO_ENABLED=0 go build -ldflags "-s -w \
|
||||||
-X github.com/tinyauthapp/tinyauth/internal/model.Version=${VERSION} \
|
-X github.com/steveiliop56/tinyauth/internal/config.Version=${VERSION} \
|
||||||
-X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${COMMIT_HASH} \
|
-X github.com/steveiliop56/tinyauth/internal/config.CommitHash=${COMMIT_HASH} \
|
||||||
-X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${BUILD_TIMESTAMP}" ./cmd/tinyauth
|
-X github.com/steveiliop56/tinyauth/internal/config.BuildTimestamp=${BUILD_TIMESTAMP}" ./cmd/tinyauth
|
||||||
|
|
||||||
# Runner
|
# Runner
|
||||||
FROM gcr.io/distroless/static-debian12:latest AS runner
|
FROM gcr.io/distroless/static-debian12:latest AS runner
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ PROD_COMPOSE := $(shell test -f "docker-compose.test.prod.yml" && echo "docker-c
|
|||||||
|
|
||||||
# Deps
|
# Deps
|
||||||
deps:
|
deps:
|
||||||
bun install --frozen-lockfile --cwd frontend
|
bun install --cwd frontend
|
||||||
go mod download
|
go mod download
|
||||||
|
|
||||||
# Clean data
|
# Clean data
|
||||||
@@ -37,9 +37,9 @@ webui: clean-webui
|
|||||||
# Build the binary
|
# Build the binary
|
||||||
binary: webui
|
binary: webui
|
||||||
CGO_ENABLED=$(CGO_ENABLED) go build -ldflags "-s -w \
|
CGO_ENABLED=$(CGO_ENABLED) go build -ldflags "-s -w \
|
||||||
-X github.com/tinyauthapp/tinyauth/internal/model.Version=${TAG_NAME} \
|
-X github.com/steveiliop56/tinyauth/internal/config.Version=${TAG_NAME} \
|
||||||
-X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${COMMIT_HASH} \
|
-X github.com/steveiliop56/tinyauth/internal/config.CommitHash=${COMMIT_HASH} \
|
||||||
-X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${BUILD_TIMESTAMP}" \
|
-X github.com/steveiliop56/tinyauth/internal/config.BuildTimestamp=${BUILD_TIMESTAMP}" \
|
||||||
-o ${BIN_NAME} ./cmd/tinyauth
|
-o ${BIN_NAME} ./cmd/tinyauth
|
||||||
|
|
||||||
# Build for amd64
|
# Build for amd64
|
||||||
|
|||||||
@@ -5,15 +5,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img alt="License" src="https://img.shields.io/github/license/tinyauthapp/tinyauth">
|
<img alt="License" src="https://img.shields.io/github/license/steveiliop56/tinyauth">
|
||||||
<img alt="Release" src="https://img.shields.io/github/v/release/tinyauthapp/tinyauth">
|
<img alt="Release" src="https://img.shields.io/github/v/release/steveiliop56/tinyauth">
|
||||||
<img alt="Issues" src="https://img.shields.io/github/issues/tinyauthapp/tinyauth">
|
<img alt="Issues" src="https://img.shields.io/github/issues/steveiliop56/tinyauth">
|
||||||
<img alt="Tinyauth CI" src="https://github.com/tinyauthapp/tinyauth/actions/workflows/ci.yml/badge.svg">
|
<img alt="Tinyauth CI" src="https://github.com/steveiliop56/tinyauth/actions/workflows/ci.yml/badge.svg">
|
||||||
<a title="Crowdin" target="_blank" href="https://crowdin.com/project/tinyauth"><img src="https://badges.crowdin.net/tinyauth/localized.svg"></a>
|
<a title="Crowdin" target="_blank" href="https://crowdin.com/project/tinyauth"><img src="https://badges.crowdin.net/tinyauth/localized.svg"></a>
|
||||||
<a href="https://scorecard.dev/viewer/?uri=github.com/tinyauthapp/tinyauth" target="_blank" title="OpenSSF Scorecard">
|
|
||||||
<img src="https://api.scorecard.dev/projects/github.com/tinyauthapp/tinyauth/badge">
|
|
||||||
</a>
|
|
||||||
<a href="https://www.bestpractices.dev/projects/12681" target="_blank" title="OSSF Best Practices"><img src="https://www.bestpractices.dev/projects/12681/baseline"></a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
@@ -43,7 +39,7 @@ If you are still not sure if Tinyauth suits your needs you can try out the [demo
|
|||||||
|
|
||||||
You can find documentation and guides on all of the available configuration of Tinyauth in the [website](https://tinyauth.app).
|
You can find documentation and guides on all of the available configuration of Tinyauth in the [website](https://tinyauth.app).
|
||||||
|
|
||||||
If you wish to contribute to the documentation head over to the [repository](https://github.com/tinyauthapp/docs).
|
If you wish to contribute to the documentation head over to the [repository](https://github.com/steveiliop56/tinyauth-docs).
|
||||||
|
|
||||||
## Discord
|
## Discord
|
||||||
|
|
||||||
@@ -51,7 +47,7 @@ Tinyauth has a [Discord](https://discord.gg/eHzVaCzRRd) server. Feel free to hop
|
|||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
All contributions to the codebase are welcome! If you have any free time, feel free to pick up an [issue](https://github.com/tinyauthapp/tinyauth/issues) or add your own missing features. Make sure to check out the [contributing guide](./CONTRIBUTING.md) for instructions on how to get the development server up and running.
|
All contributions to the codebase are welcome! If you have any free time, feel free to pick up an [issue](https://github.com/steveiliop56/tinyauth/issues) or add your own missing features. Make sure to check out the [contributing guide](./CONTRIBUTING.md) for instructions on how to get the development server up and running.
|
||||||
|
|
||||||
## Localization
|
## Localization
|
||||||
|
|
||||||
@@ -76,4 +72,4 @@ A big thank you to the following people for providing me with more coffee:
|
|||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
[](https://www.star-history.com/#tinyauthapp/tinyauth&Date)
|
[](https://www.star-history.com/#steveiliop56/tinyauth&Date)
|
||||||
|
|||||||
+2
-2
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
## Supported Versions
|
## Supported Versions
|
||||||
|
|
||||||
It is recommended to use the [latest](https://github.com/tinyauthapp/tinyauth/releases/latest) available version of tinyauth. This is because it includes security fixes, new features and dependency updates. Older versions, especially major ones, are not supported and won't receive security or patch updates.
|
It is recommended to use the [latest](https://github.com/steveiliop56/tinyauth/releases/latest) available version of tinyauth. This is because it includes security fixes, new features and dependency updates. Older versions, especially major ones, are not supported and won't receive security or patch updates.
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
Due to the nature of this app, it needs to be secure. If you discover any security issues or vulnerabilities in the app please contact me as soon as possible at <security@tinyauth.app>. Please do not use the issues section to report security issues as I won't be able to patch them in time and they may get exploited by malicious actors.
|
Due to the nature of this app, it needs to be secure. If you discover any security issues or vulnerabilities in the app please contact me as soon as possible at <steve@doesmycode.work>. Please do not use the issues section to report security issues as I won't be able to patch them in time and they may get exploited by malicious actors.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"embeds": [
|
"embeds": [
|
||||||
{
|
{
|
||||||
"title": "Welcome to Tinyauth Discord!",
|
"title": "Welcome to Tinyauth Discord!",
|
||||||
"description": "Tinyauth is a simple authentication middleware that adds a simple login screen or OAuth with Google, Github and any provider to all of your docker apps. It supports all the popular proxies like Traefik, Nginx and Caddy.\n\n**Information**\n\n• Github: <https://github.com/tinyauthapp/tinyauth>\n• Website: <https://tinyauth.app>",
|
"description": "Tinyauth is a simple authentication middleware that adds a simple login screen or OAuth with Google, Github and any provider to all of your docker apps. It supports all the popular proxies like Traefik, Nginx and Caddy.\n\n**Information**\n\n• Github: <https://github.com/steveiliop56/tinyauth>\n• Website: <https://tinyauth.app>",
|
||||||
"url": "https://tinyauth.app",
|
"url": "https://tinyauth.app",
|
||||||
"color": 7002085,
|
"color": 7002085,
|
||||||
"author": {
|
"author": {
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
},
|
},
|
||||||
"timestamp": "2025-06-06T12:25:27.629Z",
|
"timestamp": "2025-06-06T12:25:27.629Z",
|
||||||
"thumbnail": {
|
"thumbnail": {
|
||||||
"url": "https://github.com/tinyauthapp/tinyauth/blob/main/assets/logo.png?raw=true"
|
"url": "https://github.com/steveiliop56/tinyauth/blob/main/assets/logo.png?raw=true"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||||
"github.com/tinyauthapp/paerser/cli"
|
"github.com/tinyauthapp/paerser/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"charm.land/huh/v2"
|
"charm.land/huh/v2"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
"github.com/tinyauthapp/paerser/cli"
|
"github.com/tinyauthapp/paerser/cli"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -40,8 +40,7 @@ func createUserCmd() *cli.Command {
|
|||||||
Configuration: tCfg,
|
Configuration: tCfg,
|
||||||
Resources: loaders,
|
Resources: loaders,
|
||||||
Run: func(_ []string) error {
|
Run: func(_ []string) error {
|
||||||
log := logger.NewLogger().WithSimpleConfig()
|
tlog.NewSimpleLogger().Init()
|
||||||
log.Init()
|
|
||||||
|
|
||||||
if tCfg.Interactive {
|
if tCfg.Interactive {
|
||||||
form := huh.NewForm(
|
form := huh.NewForm(
|
||||||
@@ -74,7 +73,7 @@ func createUserCmd() *cli.Command {
|
|||||||
return errors.New("username and password cannot be empty")
|
return errors.New("username and password cannot be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
log.App.Info().Str("username", tCfg.Username).Msg("Creating user")
|
tlog.App.Info().Str("username", tCfg.Username).Msg("Creating user")
|
||||||
|
|
||||||
passwd, err := bcrypt.GenerateFromPassword([]byte(tCfg.Password), bcrypt.DefaultCost)
|
passwd, err := bcrypt.GenerateFromPassword([]byte(tCfg.Password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -87,7 +86,7 @@ func createUserCmd() *cli.Command {
|
|||||||
passwdStr = strings.ReplaceAll(passwdStr, "$", "$$")
|
passwdStr = strings.ReplaceAll(passwdStr, "$", "$$")
|
||||||
}
|
}
|
||||||
|
|
||||||
log.App.Info().Str("user", fmt.Sprintf("%s:%s", tCfg.Username, passwdStr)).Msg("User created")
|
tlog.App.Info().Str("user", fmt.Sprintf("%s:%s", tCfg.Username, passwdStr)).Msg("User created")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
|
|
||||||
"charm.land/huh/v2"
|
"charm.land/huh/v2"
|
||||||
"github.com/mdp/qrterminal/v3"
|
"github.com/mdp/qrterminal/v3"
|
||||||
@@ -40,8 +40,7 @@ func generateTotpCmd() *cli.Command {
|
|||||||
Configuration: tCfg,
|
Configuration: tCfg,
|
||||||
Resources: loaders,
|
Resources: loaders,
|
||||||
Run: func(_ []string) error {
|
Run: func(_ []string) error {
|
||||||
log := logger.NewLogger().WithSimpleConfig()
|
tlog.NewSimpleLogger().Init()
|
||||||
log.Init()
|
|
||||||
|
|
||||||
if tCfg.Interactive {
|
if tCfg.Interactive {
|
||||||
form := huh.NewForm(
|
form := huh.NewForm(
|
||||||
@@ -74,7 +73,7 @@ func generateTotpCmd() *cli.Command {
|
|||||||
docker = true
|
docker = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.TOTPSecret != "" {
|
if user.TotpSecret != "" {
|
||||||
return fmt.Errorf("user already has a TOTP secret")
|
return fmt.Errorf("user already has a TOTP secret")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,9 +88,9 @@ func generateTotpCmd() *cli.Command {
|
|||||||
|
|
||||||
secret := key.Secret()
|
secret := key.Secret()
|
||||||
|
|
||||||
log.App.Info().Str("secret", secret).Msg("Generated TOTP secret")
|
tlog.App.Info().Str("secret", secret).Msg("Generated TOTP secret")
|
||||||
|
|
||||||
log.App.Info().Msg("Generated QR code")
|
tlog.App.Info().Msg("Generated QR code")
|
||||||
|
|
||||||
config := qrterminal.Config{
|
config := qrterminal.Config{
|
||||||
Level: qrterminal.L,
|
Level: qrterminal.L,
|
||||||
@@ -103,14 +102,14 @@ func generateTotpCmd() *cli.Command {
|
|||||||
|
|
||||||
qrterminal.GenerateWithConfig(key.URL(), config)
|
qrterminal.GenerateWithConfig(key.URL(), config)
|
||||||
|
|
||||||
user.TOTPSecret = secret
|
user.TotpSecret = secret
|
||||||
|
|
||||||
// If using docker escape re-escape it
|
// If using docker escape re-escape it
|
||||||
if docker {
|
if docker {
|
||||||
user.Password = strings.ReplaceAll(user.Password, "$", "$$")
|
user.Password = strings.ReplaceAll(user.Password, "$", "$$")
|
||||||
}
|
}
|
||||||
|
|
||||||
log.App.Info().Str("user", fmt.Sprintf("%s:%s:%s", user.Username, user.Password, user.TOTPSecret)).Msg("Add the totp secret to your authenticator app then use the verify command to ensure everything is working correctly.")
|
tlog.App.Info().Str("user", fmt.Sprintf("%s:%s:%s", user.Username, user.Password, user.TotpSecret)).Msg("Add the totp secret to your authenticator app then use the verify command to ensure everything is working correctly.")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
"github.com/tinyauthapp/paerser/cli"
|
"github.com/tinyauthapp/paerser/cli"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type healthzResponse struct {
|
type healthzResponse struct {
|
||||||
@@ -26,8 +26,7 @@ func healthcheckCmd() *cli.Command {
|
|||||||
Resources: nil,
|
Resources: nil,
|
||||||
AllowArg: true,
|
AllowArg: true,
|
||||||
Run: func(args []string) error {
|
Run: func(args []string) error {
|
||||||
log := logger.NewLogger().WithSimpleConfig()
|
tlog.NewSimpleLogger().Init()
|
||||||
log.Init()
|
|
||||||
|
|
||||||
srvAddr := os.Getenv("TINYAUTH_SERVER_ADDRESS")
|
srvAddr := os.Getenv("TINYAUTH_SERVER_ADDRESS")
|
||||||
if srvAddr == "" {
|
if srvAddr == "" {
|
||||||
@@ -49,7 +48,7 @@ func healthcheckCmd() *cli.Command {
|
|||||||
return errors.New("Could not determine app URL")
|
return errors.New("Could not determine app URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
log.App.Info().Str("app_url", appUrl).Msg("Performing health check")
|
tlog.App.Info().Str("app_url", appUrl).Msg("Performing health check")
|
||||||
|
|
||||||
client := http.Client{
|
client := http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
@@ -87,7 +86,7 @@ func healthcheckCmd() *cli.Command {
|
|||||||
return fmt.Errorf("failed to decode response: %w", err)
|
return fmt.Errorf("failed to decode response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.App.Info().Interface("response", healthResp).Msg("Tinyauth is healthy")
|
tlog.App.Info().Interface("response", healthResp).Msg("Tinyauth is healthy")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,16 +4,17 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"charm.land/huh/v2"
|
"charm.land/huh/v2"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/bootstrap"
|
"github.com/steveiliop56/tinyauth/internal/bootstrap"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
"github.com/steveiliop56/tinyauth/internal/config"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/loaders"
|
"github.com/steveiliop56/tinyauth/internal/utils/loaders"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/tinyauthapp/paerser/cli"
|
"github.com/tinyauthapp/paerser/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
tConfig := model.NewDefaultConfiguration()
|
tConfig := config.NewDefaultConfiguration()
|
||||||
|
|
||||||
loaders := []cli.ResourceLoader{
|
loaders := []cli.ResourceLoader{
|
||||||
&loaders.FileLoader{},
|
&loaders.FileLoader{},
|
||||||
@@ -107,7 +108,12 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func runCmd(cfg model.Config) error {
|
func runCmd(cfg config.Config) error {
|
||||||
|
logger := tlog.NewLogger(cfg.Log)
|
||||||
|
logger.Init()
|
||||||
|
|
||||||
|
tlog.App.Info().Str("version", config.Version).Msg("Starting tinyauth")
|
||||||
|
|
||||||
app := bootstrap.NewBootstrapApp(cfg)
|
app := bootstrap.NewBootstrapApp(cfg)
|
||||||
|
|
||||||
err := app.Setup()
|
err := app.Setup()
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
|
|
||||||
"charm.land/huh/v2"
|
"charm.land/huh/v2"
|
||||||
"github.com/pquerna/otp/totp"
|
"github.com/pquerna/otp/totp"
|
||||||
@@ -44,8 +44,7 @@ func verifyUserCmd() *cli.Command {
|
|||||||
Configuration: tCfg,
|
Configuration: tCfg,
|
||||||
Resources: loaders,
|
Resources: loaders,
|
||||||
Run: func(_ []string) error {
|
Run: func(_ []string) error {
|
||||||
log := logger.NewLogger().WithSimpleConfig()
|
tlog.NewSimpleLogger().Init()
|
||||||
log.Init()
|
|
||||||
|
|
||||||
if tCfg.Interactive {
|
if tCfg.Interactive {
|
||||||
form := huh.NewForm(
|
form := huh.NewForm(
|
||||||
@@ -96,21 +95,21 @@ func verifyUserCmd() *cli.Command {
|
|||||||
return fmt.Errorf("password is incorrect: %w", err)
|
return fmt.Errorf("password is incorrect: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.TOTPSecret == "" {
|
if user.TotpSecret == "" {
|
||||||
if tCfg.Totp != "" {
|
if tCfg.Totp != "" {
|
||||||
log.App.Warn().Msg("User does not have TOTP secret")
|
tlog.App.Warn().Msg("User does not have TOTP secret")
|
||||||
}
|
}
|
||||||
log.App.Info().Msg("User verified")
|
tlog.App.Info().Msg("User verified")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
ok := totp.Validate(tCfg.Totp, user.TOTPSecret)
|
ok := totp.Validate(tCfg.Totp, user.TotpSecret)
|
||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("TOTP code incorrect")
|
return fmt.Errorf("TOTP code incorrect")
|
||||||
}
|
}
|
||||||
|
|
||||||
log.App.Info().Msg("User verified")
|
tlog.App.Info().Msg("User verified")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/config"
|
||||||
|
|
||||||
"github.com/tinyauthapp/paerser/cli"
|
"github.com/tinyauthapp/paerser/cli"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func versionCmd() *cli.Command {
|
func versionCmd() *cli.Command {
|
||||||
@@ -14,9 +15,9 @@ func versionCmd() *cli.Command {
|
|||||||
Configuration: nil,
|
Configuration: nil,
|
||||||
Resources: nil,
|
Resources: nil,
|
||||||
Run: func(_ []string) error {
|
Run: func(_ []string) error {
|
||||||
fmt.Printf("Version: %s\n", model.Version)
|
fmt.Printf("Version: %s\n", config.Version)
|
||||||
fmt.Printf("Commit Hash: %s\n", model.CommitHash)
|
fmt.Printf("Commit Hash: %s\n", config.CommitHash)
|
||||||
fmt.Printf("Build Timestamp: %s\n", model.BuildTimestamp)
|
fmt.Printf("Build Timestamp: %s\n", config.BuildTimestamp)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ services:
|
|||||||
traefik.http.routers.whoami.middlewares: tinyauth
|
traefik.http.routers.whoami.middlewares: tinyauth
|
||||||
|
|
||||||
tinyauth:
|
tinyauth:
|
||||||
image: ghcr.io/tinyauthapp/tinyauth:v5
|
image: ghcr.io/steveiliop56/tinyauth:v5
|
||||||
environment:
|
environment:
|
||||||
- TINYAUTH_APPURL=https://tinyauth.example.com
|
- TINYAUTH_APPURL=https://tinyauth.example.com
|
||||||
- TINYAUTH_AUTH_USERS=user:$$2a$$10$$UdLYoJ5lgPsC0RKqYH/jMua7zIn0g9kPqWmhYayJYLaZQ/FTmH2/u # user:password
|
- TINYAUTH_AUTH_USERS=user:$$2a$$10$$UdLYoJ5lgPsC0RKqYH/jMua7zIn0g9kPqWmhYayJYLaZQ/FTmH2/u # user:password
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ WORKDIR /frontend
|
|||||||
COPY ./frontend/package.json ./
|
COPY ./frontend/package.json ./
|
||||||
COPY ./frontend/bun.lock ./
|
COPY ./frontend/bun.lock ./
|
||||||
|
|
||||||
RUN bun install --frozen-lockfile
|
RUN bun install
|
||||||
|
|
||||||
COPY ./frontend/public ./public
|
COPY ./frontend/public ./public
|
||||||
COPY ./frontend/src ./src
|
COPY ./frontend/src ./src
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ interface Props {
|
|||||||
onSubmit: (data: LoginSchema) => void;
|
onSubmit: (data: LoginSchema) => void;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
formId?: string;
|
formId?: string;
|
||||||
params?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LoginForm = (props: Props) => {
|
export const LoginForm = (props: Props) => {
|
||||||
@@ -72,12 +71,6 @@ export const LoginForm = (props: Props) => {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<a
|
<a
|
||||||
href="/forgot-password"
|
href="/forgot-password"
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
window.location.replace(
|
|
||||||
`/forgot-password${props.params ? `${props.params}` : ""}`,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="text-muted-foreground hover:text-muted-foreground/80 text-sm absolute right-0 bottom-[2.565rem]" // 2.565 is *just* perfect
|
className="text-muted-foreground hover:text-muted-foreground/80 text-sm absolute right-0 bottom-[2.565rem]" // 2.565 is *just* perfect
|
||||||
>
|
>
|
||||||
{t("forgotPasswordTitle")}
|
{t("forgotPasswordTitle")}
|
||||||
|
|||||||
@@ -79,6 +79,5 @@
|
|||||||
"profileScopeName": "Profile",
|
"profileScopeName": "Profile",
|
||||||
"profileScopeDescription": "Allows the app to access your profile information.",
|
"profileScopeDescription": "Allows the app to access your profile information.",
|
||||||
"groupsScopeName": "Groups",
|
"groupsScopeName": "Groups",
|
||||||
"groupsScopeDescription": "Allows the app to access your group information.",
|
"groupsScopeDescription": "Allows the app to access your group information."
|
||||||
"backToLoginButton": "Back to login"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,10 +79,5 @@
|
|||||||
"profileScopeName": "Profile",
|
"profileScopeName": "Profile",
|
||||||
"profileScopeDescription": "Allows the app to access your profile information.",
|
"profileScopeDescription": "Allows the app to access your profile information.",
|
||||||
"groupsScopeName": "Groups",
|
"groupsScopeName": "Groups",
|
||||||
"groupsScopeDescription": "Allows the app to access your group information.",
|
"groupsScopeDescription": "Allows the app to access your group information."
|
||||||
"backToLoginButton": "Back to login",
|
|
||||||
"phoneScopeName": "Phone",
|
|
||||||
"phoneScopeDescription": "Allows the app to access your phone number.",
|
|
||||||
"addressScopeName": "Address",
|
|
||||||
"addressScopeDescription": "Allows the app to access your address."
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,8 +58,8 @@
|
|||||||
"invalidInput": "Input non valido",
|
"invalidInput": "Input non valido",
|
||||||
"domainWarningTitle": "Dominio non valido",
|
"domainWarningTitle": "Dominio non valido",
|
||||||
"domainWarningSubtitle": "Stai accedendo a questa istanza da un dominio errato. Scegliendo di procedere, potresti incontrare problemi con l'autenticazione.",
|
"domainWarningSubtitle": "Stai accedendo a questa istanza da un dominio errato. Scegliendo di procedere, potresti incontrare problemi con l'autenticazione.",
|
||||||
"domainWarningCurrent": "Current:",
|
"domainWarningCurrent": "Attuale:",
|
||||||
"domainWarningExpected": "Expected:",
|
"domainWarningExpected": "Previsto:",
|
||||||
"ignoreTitle": "Ignora",
|
"ignoreTitle": "Ignora",
|
||||||
"goToCorrectDomainTitle": "Vai al dominio corretto",
|
"goToCorrectDomainTitle": "Vai al dominio corretto",
|
||||||
"authorizeTitle": "Autorizza",
|
"authorizeTitle": "Autorizza",
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
"fieldRequired": "Ово поље је неопходно",
|
"fieldRequired": "Ово поље је неопходно",
|
||||||
"invalidInput": "Неисправан унос",
|
"invalidInput": "Неисправан унос",
|
||||||
"domainWarningTitle": "Неисправан домен",
|
"domainWarningTitle": "Неисправан домен",
|
||||||
"domainWarningSubtitle": "You are accessing this instance from an incorrect domain. If you proceed, you may encounter issues with authentication.",
|
"domainWarningSubtitle": "Приступате овој инстанци са неисправног домена. Ако наставите, можете наићи на проблеме са аутентификацијом.",
|
||||||
"domainWarningCurrent": "Тренутни:",
|
"domainWarningCurrent": "Тренутни:",
|
||||||
"domainWarningExpected": "Очекивани:",
|
"domainWarningExpected": "Очекивани:",
|
||||||
"ignoreTitle": "Игнориши",
|
"ignoreTitle": "Игнориши",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { toast } from "sonner";
|
|||||||
import { useOIDCParams } from "@/lib/hooks/oidc";
|
import { useOIDCParams } from "@/lib/hooks/oidc";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TFunction } from "i18next";
|
import { TFunction } from "i18next";
|
||||||
import { Mail, MapPin, Phone, Shield, User, Users } from "lucide-react";
|
import { Mail, Shield, User, Users } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -61,18 +61,6 @@ const createScopeMap = (t: TFunction<"translation", undefined>): Scope[] => {
|
|||||||
description: t("groupsScopeDescription"),
|
description: t("groupsScopeDescription"),
|
||||||
icon: <Users {...scopeMapIconProps} />,
|
icon: <Users {...scopeMapIconProps} />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "phone",
|
|
||||||
name: t("phoneScopeName"),
|
|
||||||
description: t("phoneScopeDescription"),
|
|
||||||
icon: <Phone {...scopeMapIconProps} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "address",
|
|
||||||
name: t("addressScopeName"),
|
|
||||||
description: t("addressScopeDescription"),
|
|
||||||
icon: <MapPin {...scopeMapIconProps} />,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,12 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { useAppContext } from "@/context/app-context";
|
import { useAppContext } from "@/context/app-context";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Markdown from "react-markdown";
|
import Markdown from "react-markdown";
|
||||||
import { useLocation } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
|
|
||||||
export const ForgotPasswordPage = () => {
|
export const ForgotPasswordPage = () => {
|
||||||
const { forgotPasswordMessage } = useAppContext();
|
const { forgotPasswordMessage } = useAppContext();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { search } = useLocation();
|
const navigate = useNavigate();
|
||||||
const searchParams = new URLSearchParams(search);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -37,13 +36,10 @@ export const ForgotPasswordPage = () => {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const eparams = searchParams.toString();
|
navigate("/login");
|
||||||
window.location.replace(
|
|
||||||
`/login${eparams.length > 0 ? `?${eparams}` : ""}`,
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("backToLoginButton")}
|
{t("notFoundButton")}
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -264,10 +264,6 @@ export const LoginPage = () => {
|
|||||||
onSubmit={(values) => loginMutate(values)}
|
onSubmit={(values) => loginMutate(values)}
|
||||||
loading={loginIsPending || oauthIsPending}
|
loading={loginIsPending || oauthIsPending}
|
||||||
formId={formId}
|
formId={formId}
|
||||||
params={(() => {
|
|
||||||
const eparams = searchParams.toString();
|
|
||||||
return eparams.length > 0 ? `?${eparams}` : "";
|
|
||||||
})()}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{providers.length == 0 && (
|
{providers.length == 0 && (
|
||||||
|
|||||||
+2
-2
@@ -10,7 +10,7 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
"github.com/steveiliop56/tinyauth/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type EnvEntry struct {
|
type EnvEntry struct {
|
||||||
@@ -20,7 +20,7 @@ type EnvEntry struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func generateExampleEnv() {
|
func generateExampleEnv() {
|
||||||
cfg := model.NewDefaultConfiguration()
|
cfg := config.NewDefaultConfiguration()
|
||||||
entries := make([]EnvEntry, 0)
|
entries := make([]EnvEntry, 0)
|
||||||
|
|
||||||
root := reflect.TypeOf(cfg).Elem()
|
root := reflect.TypeOf(cfg).Elem()
|
||||||
|
|||||||
+2
-2
@@ -10,7 +10,7 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
"github.com/steveiliop56/tinyauth/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MarkdownEntry struct {
|
type MarkdownEntry struct {
|
||||||
@@ -21,7 +21,7 @@ type MarkdownEntry struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func generateMarkdown() {
|
func generateMarkdown() {
|
||||||
cfg := model.NewDefaultConfiguration()
|
cfg := config.NewDefaultConfiguration()
|
||||||
entries := make([]MarkdownEntry, 0)
|
entries := make([]MarkdownEntry, 0)
|
||||||
|
|
||||||
root := reflect.TypeOf(cfg).Elem()
|
root := reflect.TypeOf(cfg).Elem()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module github.com/tinyauthapp/tinyauth
|
module github.com/steveiliop56/tinyauth
|
||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
@@ -14,15 +14,15 @@ require (
|
|||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/mdp/qrterminal/v3 v3.2.1
|
github.com/mdp/qrterminal/v3 v3.2.1
|
||||||
github.com/pquerna/otp v1.5.0
|
github.com/pquerna/otp v1.5.0
|
||||||
github.com/rs/zerolog v1.35.1
|
github.com/rs/zerolog v1.35.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/tinyauthapp/paerser v0.0.0-20260410140347-85c3740d6298
|
github.com/tinyauthapp/paerser v0.0.0-20260410140347-85c3740d6298
|
||||||
github.com/weppos/publicsuffix-go v0.50.3
|
github.com/weppos/publicsuffix-go v0.50.3
|
||||||
golang.org/x/crypto v0.50.0
|
golang.org/x/crypto v0.50.0
|
||||||
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546
|
||||||
golang.org/x/oauth2 v0.36.0
|
golang.org/x/oauth2 v0.36.0
|
||||||
k8s.io/apimachinery v0.36.0
|
gotest.tools/v3 v3.5.2
|
||||||
k8s.io/client-go v0.36.0
|
modernc.org/sqlite v1.48.2
|
||||||
modernc.org/sqlite v1.50.0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -30,7 +30,7 @@ require (
|
|||||||
charm.land/bubbletea/v2 v2.0.2 // indirect
|
charm.land/bubbletea/v2 v2.0.2 // indirect
|
||||||
charm.land/lipgloss/v2 v2.0.1 // indirect
|
charm.land/lipgloss/v2 v2.0.1 // indirect
|
||||||
dario.cat/mergo v1.0.1 // indirect
|
dario.cat/mergo v1.0.1 // indirect
|
||||||
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
||||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||||
github.com/Masterminds/goutils v1.1.1 // indirect
|
github.com/Masterminds/goutils v1.1.1 // indirect
|
||||||
github.com/Masterminds/semver/v3 v3.3.0 // indirect
|
github.com/Masterminds/semver/v3 v3.3.0 // indirect
|
||||||
@@ -63,7 +63,6 @@ require (
|
|||||||
github.com/docker/go-units v0.5.0 // indirect
|
github.com/docker/go-units v0.5.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||||
@@ -74,6 +73,7 @@ require (
|
|||||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
github.com/huandu/xstrings v1.5.0 // indirect
|
github.com/huandu/xstrings v1.5.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
@@ -90,9 +90,8 @@ require (
|
|||||||
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
||||||
github.com/moby/term v0.5.2 // indirect
|
github.com/moby/term v0.5.2 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
github.com/opencontainers/image-spec v1.1.0 // indirect
|
github.com/opencontainers/image-spec v1.1.0 // indirect
|
||||||
@@ -107,7 +106,6 @@ require (
|
|||||||
github.com/spf13/cast v1.10.0 // indirect
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
@@ -118,28 +116,16 @@ require (
|
|||||||
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
|
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
|
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
|
||||||
golang.org/x/arch v0.22.0 // indirect
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
|
||||||
golang.org/x/net v0.52.0 // indirect
|
golang.org/x/net v0.52.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
golang.org/x/sys v0.43.0 // indirect
|
||||||
golang.org/x/term v0.42.0 // indirect
|
golang.org/x/term v0.42.0 // indirect
|
||||||
golang.org/x/text v0.36.0 // indirect
|
golang.org/x/text v0.36.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
|
|
||||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
gotest.tools/v3 v3.5.2 // indirect
|
modernc.org/libc v1.70.0 // indirect
|
||||||
k8s.io/klog/v2 v2.140.0 // indirect
|
|
||||||
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
|
|
||||||
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
|
|
||||||
modernc.org/libc v1.72.0 // indirect
|
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
rsc.io/qr v0.2.0 // indirect
|
rsc.io/qr v0.2.0 // indirect
|
||||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
|
||||||
sigs.k8s.io/randfill v1.0.0 // indirect
|
|
||||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect
|
|
||||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
|
|||||||
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
|
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
|
||||||
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||||
@@ -97,14 +97,10 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
|
|||||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
|
|
||||||
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
@@ -122,12 +118,6 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
|||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
|
||||||
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
|
||||||
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
|
|
||||||
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
|
|
||||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
|
||||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
@@ -142,8 +132,6 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
|||||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||||
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
|
|
||||||
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
@@ -174,8 +162,6 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6
|
|||||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||||
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
@@ -190,8 +176,6 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
|||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
|
||||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
@@ -219,15 +203,12 @@ github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFL
|
|||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
|
|
||||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
|
||||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
@@ -253,20 +234,17 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
|||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
|
github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI=
|
||||||
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
@@ -283,8 +261,6 @@ github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY
|
|||||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
github.com/weppos/publicsuffix-go v0.50.3 h1:eT5dcjHQcVDNc0igpFEsGHKIip30feuB2zuuI9eJxiE=
|
github.com/weppos/publicsuffix-go v0.50.3 h1:eT5dcjHQcVDNc0igpFEsGHKIip30feuB2zuuI9eJxiE=
|
||||||
github.com/weppos/publicsuffix-go v0.50.3/go.mod h1:/rOa781xBykZhHK/I3QeHo92qdDKVmKZKF7s8qAEM/4=
|
github.com/weppos/publicsuffix-go v0.50.3/go.mod h1:/rOa781xBykZhHK/I3QeHo92qdDKVmKZKF7s8qAEM/4=
|
||||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
|
||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
@@ -311,10 +287,6 @@ go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpu
|
|||||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
|
||||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
|
||||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
|
||||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||||
@@ -336,8 +308,8 @@ golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
|||||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||||
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
|
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
|
||||||
@@ -347,36 +319,20 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:
|
|||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
|
|
||||||
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
|
||||||
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
|
||||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||||
k8s.io/api v0.36.0 h1:SgqDhZzHdOtMk40xVSvCXkP9ME0H05hPM3p9AB1kL80=
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
k8s.io/api v0.36.0/go.mod h1:m1LVrGPNYax5NBHdO+QuAedXyuzTt4RryI/qnmNvs34=
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
k8s.io/apimachinery v0.36.0 h1:jZyPzhd5Z+3h9vJLt0z9XdzW9VzNzWAUw+P1xZ9PXtQ=
|
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||||
k8s.io/apimachinery v0.36.0/go.mod h1:FklypaRJt6n5wUIwWXIP6GJlIpUizTgfo1T/As+Tyxc=
|
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||||
k8s.io/client-go v0.36.0 h1:pOYi7C4RHChYjMiHpZSpSbIM6ZxVbRXBy7CuiIwqA3c=
|
|
||||||
k8s.io/client-go v0.36.0/go.mod h1:ZKKcpwF0aLYfkHFCjillCKaTK/yBkEDHTDXCFY6AS9Y=
|
|
||||||
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
|
|
||||||
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
|
|
||||||
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg=
|
|
||||||
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0=
|
|
||||||
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
|
|
||||||
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
|
|
||||||
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
|
|
||||||
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
|
|
||||||
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
|
|
||||||
modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
|
|
||||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
@@ -385,8 +341,8 @@ modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
|||||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
|
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||||
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
|
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
@@ -395,19 +351,11 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
|||||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
|
modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
|
||||||
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
|
modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||||
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
|
|
||||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
|
|
||||||
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
|
|
||||||
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
|
|
||||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8=
|
|
||||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
|
|
||||||
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
|
||||||
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
ALTER TABLE "oidc_userinfo" DROP COLUMN "given_name";
|
|
||||||
ALTER TABLE "oidc_userinfo" DROP COLUMN "family_name";
|
|
||||||
ALTER TABLE "oidc_userinfo" DROP COLUMN "middle_name";
|
|
||||||
ALTER TABLE "oidc_userinfo" DROP COLUMN "nickname";
|
|
||||||
ALTER TABLE "oidc_userinfo" DROP COLUMN "profile";
|
|
||||||
ALTER TABLE "oidc_userinfo" DROP COLUMN "picture";
|
|
||||||
ALTER TABLE "oidc_userinfo" DROP COLUMN "website";
|
|
||||||
ALTER TABLE "oidc_userinfo" DROP COLUMN "gender";
|
|
||||||
ALTER TABLE "oidc_userinfo" DROP COLUMN "birthdate";
|
|
||||||
ALTER TABLE "oidc_userinfo" DROP COLUMN "zoneinfo";
|
|
||||||
ALTER TABLE "oidc_userinfo" DROP COLUMN "locale";
|
|
||||||
ALTER TABLE "oidc_userinfo" DROP COLUMN "phone_number";
|
|
||||||
ALTER TABLE "oidc_userinfo" DROP COLUMN "address";
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
ALTER TABLE "oidc_userinfo" ADD COLUMN "given_name" TEXT NOT NULL DEFAULT "";
|
|
||||||
ALTER TABLE "oidc_userinfo" ADD COLUMN "family_name" TEXT NOT NULL DEFAULT "";
|
|
||||||
ALTER TABLE "oidc_userinfo" ADD COLUMN "middle_name" TEXT NOT NULL DEFAULT "";
|
|
||||||
ALTER TABLE "oidc_userinfo" ADD COLUMN "nickname" TEXT NOT NULL DEFAULT "";
|
|
||||||
ALTER TABLE "oidc_userinfo" ADD COLUMN "profile" TEXT NOT NULL DEFAULT "";
|
|
||||||
ALTER TABLE "oidc_userinfo" ADD COLUMN "picture" TEXT NOT NULL DEFAULT "";
|
|
||||||
ALTER TABLE "oidc_userinfo" ADD COLUMN "website" TEXT NOT NULL DEFAULT "";
|
|
||||||
ALTER TABLE "oidc_userinfo" ADD COLUMN "gender" TEXT NOT NULL DEFAULT "";
|
|
||||||
ALTER TABLE "oidc_userinfo" ADD COLUMN "birthdate" TEXT NOT NULL DEFAULT "";
|
|
||||||
ALTER TABLE "oidc_userinfo" ADD COLUMN "zoneinfo" TEXT NOT NULL DEFAULT "";
|
|
||||||
ALTER TABLE "oidc_userinfo" ADD COLUMN "locale" TEXT NOT NULL DEFAULT "";
|
|
||||||
ALTER TABLE "oidc_userinfo" ADD COLUMN "phone_number" TEXT NOT NULL DEFAULT "";
|
|
||||||
ALTER TABLE "oidc_userinfo" ADD COLUMN "address" TEXT NOT NULL DEFAULT "{}";
|
|
||||||
+129
-282
@@ -3,187 +3,154 @@ package bootstrap
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/steveiliop56/tinyauth/internal/config"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
"github.com/steveiliop56/tinyauth/internal/controller"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
"github.com/steveiliop56/tinyauth/internal/repository"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Services struct {
|
|
||||||
accessControlService *service.AccessControlsService
|
|
||||||
authService *service.AuthService
|
|
||||||
dockerService *service.DockerService
|
|
||||||
kubernetesService *service.KubernetesService
|
|
||||||
ldapService *service.LdapService
|
|
||||||
oauthBrokerService *service.OAuthBrokerService
|
|
||||||
oidcService *service.OIDCService
|
|
||||||
}
|
|
||||||
|
|
||||||
type BootstrapApp struct {
|
type BootstrapApp struct {
|
||||||
config model.Config
|
config config.Config
|
||||||
runtime model.RuntimeConfig
|
context struct {
|
||||||
|
appUrl string
|
||||||
|
uuid string
|
||||||
|
cookieDomain string
|
||||||
|
sessionCookieName string
|
||||||
|
csrfCookieName string
|
||||||
|
redirectCookieName string
|
||||||
|
oauthSessionCookieName string
|
||||||
|
users []config.User
|
||||||
|
oauthProviders map[string]config.OAuthServiceConfig
|
||||||
|
configuredProviders []controller.Provider
|
||||||
|
oidcClients []config.OIDCClientConfig
|
||||||
|
}
|
||||||
services Services
|
services Services
|
||||||
log *logger.Logger
|
|
||||||
ctx context.Context
|
|
||||||
cancel context.CancelFunc
|
|
||||||
queries *repository.Queries
|
|
||||||
router *gin.Engine
|
|
||||||
db *sql.DB
|
|
||||||
wg sync.WaitGroup
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewBootstrapApp(config model.Config) *BootstrapApp {
|
func NewBootstrapApp(config config.Config) *BootstrapApp {
|
||||||
return &BootstrapApp{
|
return &BootstrapApp{
|
||||||
config: config,
|
config: config,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *BootstrapApp) Setup() error {
|
func (app *BootstrapApp) Setup() error {
|
||||||
// create context
|
fmt.Println("Tinyauth is moving to an organization! All versions after v5.0.7 will be released under ghcr.io/tinyauthapp/tinyauth. Existing images will continue to work but new features and updates (including security ones) will only be released under the new image path.")
|
||||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
||||||
app.ctx = ctx
|
|
||||||
app.cancel = cancel
|
|
||||||
|
|
||||||
// setup logger
|
|
||||||
log := logger.NewLogger().WithConfig(app.config.Log)
|
|
||||||
log.Init()
|
|
||||||
app.log = log
|
|
||||||
|
|
||||||
// get app url
|
// get app url
|
||||||
if app.config.AppURL == "" {
|
|
||||||
return errors.New("app url cannot be empty, perhaps config loading failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
appUrl, err := url.Parse(app.config.AppURL)
|
appUrl, err := url.Parse(app.config.AppURL)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse app url: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
app.runtime.AppURL = appUrl.Scheme + "://" + appUrl.Host
|
app.context.appUrl = appUrl.Scheme + "://" + appUrl.Host
|
||||||
|
|
||||||
// validate session config
|
// validate session config
|
||||||
if app.config.Auth.SessionMaxLifetime != 0 && app.config.Auth.SessionMaxLifetime < app.config.Auth.SessionExpiry {
|
if app.config.Auth.SessionMaxLifetime != 0 && app.config.Auth.SessionMaxLifetime < app.config.Auth.SessionExpiry {
|
||||||
return errors.New("session max lifetime cannot be less than session expiry")
|
return fmt.Errorf("session max lifetime cannot be less than session expiry")
|
||||||
}
|
}
|
||||||
|
|
||||||
// parse users
|
// Parse users
|
||||||
users, err := utils.GetUsers(app.config.Auth.Users, app.config.Auth.UsersFile, app.config.Auth.UserAttributes)
|
users, err := utils.GetUsers(app.config.Auth.Users, app.config.Auth.UsersFile)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to load users: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
app.runtime.LocalUsers = *users
|
app.context.users = users
|
||||||
|
|
||||||
// load oauth whitelist
|
// Setup OAuth providers
|
||||||
oauthWhitelist, err := utils.GetStringList(app.config.OAuth.Whitelist, app.config.OAuth.WhitelistFile)
|
app.context.oauthProviders = app.config.OAuth.Providers
|
||||||
|
|
||||||
if err != nil {
|
for name, provider := range app.context.oauthProviders {
|
||||||
return fmt.Errorf("failed to load oauth whitelist: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
app.runtime.OAuthWhitelist = oauthWhitelist
|
|
||||||
|
|
||||||
// Setup oauth providers
|
|
||||||
app.runtime.OAuthProviders = app.config.OAuth.Providers
|
|
||||||
|
|
||||||
for id, provider := range app.runtime.OAuthProviders {
|
|
||||||
secret := utils.GetSecret(provider.ClientSecret, provider.ClientSecretFile)
|
secret := utils.GetSecret(provider.ClientSecret, provider.ClientSecretFile)
|
||||||
provider.ClientSecret = secret
|
provider.ClientSecret = secret
|
||||||
provider.ClientSecretFile = ""
|
provider.ClientSecretFile = ""
|
||||||
|
|
||||||
if provider.RedirectURL == "" {
|
if provider.RedirectURL == "" {
|
||||||
provider.RedirectURL = app.runtime.AppURL + "/api/oauth/callback/" + id
|
provider.RedirectURL = app.context.appUrl + "/api/oauth/callback/" + name
|
||||||
}
|
}
|
||||||
|
|
||||||
app.runtime.OAuthProviders[id] = provider
|
app.context.oauthProviders[name] = provider
|
||||||
}
|
}
|
||||||
|
|
||||||
// set presets for built-in providers
|
for id, provider := range app.context.oauthProviders {
|
||||||
for id, provider := range app.runtime.OAuthProviders {
|
|
||||||
if provider.Name == "" {
|
if provider.Name == "" {
|
||||||
if name, ok := model.OverrideProviders[id]; ok {
|
if name, ok := config.OverrideProviders[id]; ok {
|
||||||
provider.Name = name
|
provider.Name = name
|
||||||
} else {
|
} else {
|
||||||
provider.Name = utils.Capitalize(id)
|
provider.Name = utils.Capitalize(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
app.runtime.OAuthProviders[id] = provider
|
app.context.oauthProviders[id] = provider
|
||||||
}
|
}
|
||||||
|
|
||||||
// setup oidc clients
|
// Setup OIDC clients
|
||||||
for id, client := range app.config.OIDC.Clients {
|
for id, client := range app.config.OIDC.Clients {
|
||||||
client.ID = id
|
client.ID = id
|
||||||
app.runtime.OIDCClients = append(app.runtime.OIDCClients, client)
|
app.context.oidcClients = append(app.context.oidcClients, client)
|
||||||
}
|
}
|
||||||
|
|
||||||
// cookie domain
|
// Get cookie domain
|
||||||
cookieDomainResolver := utils.GetCookieDomain
|
cookieDomain, err := utils.GetCookieDomain(app.context.appUrl)
|
||||||
|
|
||||||
if !app.config.Auth.SubdomainsEnabled {
|
|
||||||
app.log.App.Warn().Msg("Subdomains are disabled, using standalone cookie domain resolver which will not work with subdomains")
|
|
||||||
cookieDomainResolver = utils.GetStandaloneCookieDomain
|
|
||||||
}
|
|
||||||
|
|
||||||
cookieDomain, err := cookieDomainResolver(app.runtime.AppURL)
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get cookie domain: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
app.runtime.CookieDomain = cookieDomain
|
app.context.cookieDomain = cookieDomain
|
||||||
|
|
||||||
// cookie names
|
// Cookie names
|
||||||
app.runtime.UUID = utils.GenerateUUID(appUrl.Hostname())
|
app.context.uuid = utils.GenerateUUID(appUrl.Hostname())
|
||||||
|
cookieId := strings.Split(app.context.uuid, "-")[0]
|
||||||
|
app.context.sessionCookieName = fmt.Sprintf("%s-%s", config.SessionCookieName, cookieId)
|
||||||
|
app.context.csrfCookieName = fmt.Sprintf("%s-%s", config.CSRFCookieName, cookieId)
|
||||||
|
app.context.redirectCookieName = fmt.Sprintf("%s-%s", config.RedirectCookieName, cookieId)
|
||||||
|
app.context.oauthSessionCookieName = fmt.Sprintf("%s-%s", config.OAuthSessionCookieName, cookieId)
|
||||||
|
|
||||||
cookieId := strings.Split(app.runtime.UUID, "-")[0] // first 8 characters of the uuid should be good enough
|
// Dumps
|
||||||
|
tlog.App.Trace().Interface("config", app.config).Msg("Config dump")
|
||||||
|
tlog.App.Trace().Interface("users", app.context.users).Msg("Users dump")
|
||||||
|
tlog.App.Trace().Interface("oauthProviders", app.context.oauthProviders).Msg("OAuth providers dump")
|
||||||
|
tlog.App.Trace().Str("cookieDomain", app.context.cookieDomain).Msg("Cookie domain")
|
||||||
|
tlog.App.Trace().Str("sessionCookieName", app.context.sessionCookieName).Msg("Session cookie name")
|
||||||
|
tlog.App.Trace().Str("csrfCookieName", app.context.csrfCookieName).Msg("CSRF cookie name")
|
||||||
|
tlog.App.Trace().Str("redirectCookieName", app.context.redirectCookieName).Msg("Redirect cookie name")
|
||||||
|
|
||||||
app.runtime.SessionCookieName = fmt.Sprintf("%s-%s", model.SessionCookieName, cookieId)
|
// Database
|
||||||
app.runtime.CSRFCookieName = fmt.Sprintf("%s-%s", model.CSRFCookieName, cookieId)
|
db, err := app.SetupDatabase(app.config.Database.Path)
|
||||||
app.runtime.RedirectCookieName = fmt.Sprintf("%s-%s", model.RedirectCookieName, cookieId)
|
|
||||||
app.runtime.OAuthSessionCookieName = fmt.Sprintf("%s-%s", model.OAuthSessionCookieName, cookieId)
|
|
||||||
|
|
||||||
// database
|
|
||||||
err = app.SetupDatabase()
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to setup database: %w", err)
|
return fmt.Errorf("failed to setup database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// queries
|
// Queries
|
||||||
queries := repository.New(app.db)
|
queries := repository.New(db)
|
||||||
app.queries = queries
|
|
||||||
|
|
||||||
// services
|
// Services
|
||||||
err = app.setupServices()
|
services, err := app.initServices(queries)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to initialize services: %w", err)
|
return fmt.Errorf("failed to initialize services: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// configured providers
|
app.services = services
|
||||||
configuredProviders := make([]model.Provider, 0)
|
|
||||||
|
|
||||||
for id, provider := range app.runtime.OAuthProviders {
|
// Configured providers
|
||||||
configuredProviders = append(configuredProviders, model.Provider{
|
configuredProviders := make([]controller.Provider, 0)
|
||||||
|
|
||||||
|
for id, provider := range app.context.oauthProviders {
|
||||||
|
configuredProviders = append(configuredProviders, controller.Provider{
|
||||||
Name: provider.Name,
|
Name: provider.Name,
|
||||||
ID: id,
|
ID: id,
|
||||||
OAuth: true,
|
OAuth: true,
|
||||||
@@ -194,173 +161,70 @@ func (app *BootstrapApp) Setup() error {
|
|||||||
return configuredProviders[i].Name < configuredProviders[j].Name
|
return configuredProviders[i].Name < configuredProviders[j].Name
|
||||||
})
|
})
|
||||||
|
|
||||||
if app.services.authService.LocalAuthConfigured() {
|
if services.authService.LocalAuthConfigured() {
|
||||||
configuredProviders = append(configuredProviders, model.Provider{
|
configuredProviders = append(configuredProviders, controller.Provider{
|
||||||
Name: "Local",
|
Name: "Local",
|
||||||
ID: "local",
|
ID: "local",
|
||||||
OAuth: false,
|
OAuth: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if app.services.authService.LDAPAuthConfigured() {
|
if services.authService.LdapAuthConfigured() {
|
||||||
configuredProviders = append(configuredProviders, model.Provider{
|
configuredProviders = append(configuredProviders, controller.Provider{
|
||||||
Name: "LDAP",
|
Name: "LDAP",
|
||||||
ID: "ldap",
|
ID: "ldap",
|
||||||
OAuth: false,
|
OAuth: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tlog.App.Debug().Interface("providers", configuredProviders).Msg("Authentication providers")
|
||||||
|
|
||||||
if len(configuredProviders) == 0 {
|
if len(configuredProviders) == 0 {
|
||||||
return errors.New("no authentication providers configured")
|
return fmt.Errorf("no authentication providers configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, provider := range configuredProviders {
|
app.context.configuredProviders = configuredProviders
|
||||||
app.log.App.Debug().Str("provider", provider.Name).Msg("Configured authentication provider")
|
|
||||||
}
|
|
||||||
|
|
||||||
app.runtime.ConfiguredProviders = configuredProviders
|
// Setup router
|
||||||
|
router, err := app.setupRouter()
|
||||||
// setup router
|
|
||||||
err = app.setupRouter()
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to setup routes: %w", err)
|
return fmt.Errorf("failed to setup routes: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// start db cleanup routine
|
// Start db cleanup routine
|
||||||
app.log.App.Debug().Msg("Starting database cleanup routine")
|
tlog.App.Debug().Msg("Starting database cleanup routine")
|
||||||
app.wg.Go(app.dbCleanupRoutine)
|
go app.dbCleanupRoutine(queries)
|
||||||
|
|
||||||
// if analytics are not disabled, start heartbeat
|
// If analytics are not disabled, start heartbeat
|
||||||
if app.config.Analytics.Enabled {
|
if app.config.Analytics.Enabled {
|
||||||
app.log.App.Debug().Msg("Starting heartbeat routine")
|
tlog.App.Debug().Msg("Starting heartbeat routine")
|
||||||
app.wg.Go(app.heartbeatRoutine)
|
go app.heartbeatRoutine()
|
||||||
}
|
}
|
||||||
|
|
||||||
// create err channel to listen for server errors
|
// If we have an socket path, bind to it
|
||||||
errChanLen := 0
|
if app.config.Server.SocketPath != "" {
|
||||||
|
if _, err := os.Stat(app.config.Server.SocketPath); err == nil {
|
||||||
runUnix := app.config.Server.SocketPath != ""
|
tlog.App.Info().Msgf("Removing existing socket file %s", app.config.Server.SocketPath)
|
||||||
runHTTP := app.config.Server.SocketPath == "" || app.config.Server.ConcurrentListenersEnabled
|
err := os.Remove(app.config.Server.SocketPath)
|
||||||
|
|
||||||
if runUnix {
|
|
||||||
errChanLen++
|
|
||||||
}
|
|
||||||
|
|
||||||
if runHTTP {
|
|
||||||
errChanLen++
|
|
||||||
}
|
|
||||||
|
|
||||||
errChan := make(chan error, errChanLen)
|
|
||||||
|
|
||||||
if app.config.Server.ConcurrentListenersEnabled {
|
|
||||||
app.log.App.Info().Msg("Concurrent listeners enabled, will run on all available listeners")
|
|
||||||
}
|
|
||||||
|
|
||||||
// serve unix
|
|
||||||
if runUnix {
|
|
||||||
app.wg.Go(func() {
|
|
||||||
if err := app.serveUnix(); err != nil {
|
|
||||||
errChan <- err
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// serve to http
|
|
||||||
if runHTTP {
|
|
||||||
app.wg.Go(func() {
|
|
||||||
if err := app.serveHTTP(); err != nil {
|
|
||||||
errChan <- err
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// monitor cancellation and server errors
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-app.ctx.Done():
|
|
||||||
app.wg.Wait()
|
|
||||||
app.log.App.Debug().Msg("Closing database")
|
|
||||||
app.db.Close()
|
|
||||||
app.log.App.Info().Msg("Oh, it's time for me to go, bye!")
|
|
||||||
return nil
|
|
||||||
case err := <-errChan:
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("server error: %w", err)
|
return fmt.Errorf("failed to remove existing socket file: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *BootstrapApp) serveHTTP() error {
|
tlog.App.Info().Msgf("Starting server on unix socket %s", app.config.Server.SocketPath)
|
||||||
address := fmt.Sprintf("%s:%d", app.config.Server.Address, app.config.Server.Port)
|
if err := router.RunUnix(app.config.Server.SocketPath); err != nil {
|
||||||
|
tlog.App.Fatal().Err(err).Msg("Failed to start server")
|
||||||
|
}
|
||||||
|
|
||||||
app.log.App.Info().Msgf("Starting server on %s", address)
|
|
||||||
|
|
||||||
server := &http.Server{
|
|
||||||
Addr: address,
|
|
||||||
Handler: app.router.Handler(),
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
<-app.ctx.Done()
|
|
||||||
app.log.App.Debug().Msg("Shutting down http listener")
|
|
||||||
server.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
err := server.ListenAndServe()
|
|
||||||
|
|
||||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
||||||
return fmt.Errorf("failed to start http listener: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *BootstrapApp) serveUnix() error {
|
|
||||||
if app.config.Server.SocketPath == "" {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := os.Stat(app.config.Server.SocketPath)
|
// Start server
|
||||||
|
address := fmt.Sprintf("%s:%d", app.config.Server.Address, app.config.Server.Port)
|
||||||
if err == nil {
|
tlog.App.Info().Msgf("Starting server on %s", address)
|
||||||
app.log.App.Info().Msgf("Removing existing socket file %s", app.config.Server.SocketPath)
|
if err := router.Run(address); err != nil {
|
||||||
err := os.Remove(app.config.Server.SocketPath)
|
tlog.App.Fatal().Err(err).Msg("Failed to start server")
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to remove existing socket file: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
app.log.App.Info().Msgf("Starting server on unix socket %s", app.config.Server.SocketPath)
|
|
||||||
|
|
||||||
listener, err := net.Listen("unix", app.config.Server.SocketPath)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create unix socket listener: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
server := &http.Server{
|
|
||||||
Handler: app.router.Handler(),
|
|
||||||
}
|
|
||||||
|
|
||||||
defer server.Close()
|
|
||||||
defer listener.Close()
|
|
||||||
defer os.Remove(app.config.Server.SocketPath)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
<-app.ctx.Done()
|
|
||||||
app.log.App.Debug().Msg("Shutting down unix socket listener")
|
|
||||||
server.Close()
|
|
||||||
listener.Close()
|
|
||||||
os.Remove(app.config.Server.SocketPath)
|
|
||||||
}()
|
|
||||||
|
|
||||||
err = server.Serve(listener)
|
|
||||||
|
|
||||||
if err != nil && !errors.Is(err, net.ErrClosed) && !errors.Is(err, http.ErrServerClosed) {
|
|
||||||
return fmt.Errorf("failed to start unix socket listener: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -370,20 +234,20 @@ func (app *BootstrapApp) heartbeatRoutine() {
|
|||||||
ticker := time.NewTicker(time.Duration(12) * time.Hour)
|
ticker := time.NewTicker(time.Duration(12) * time.Hour)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
type Heartbeat struct {
|
type heartbeat struct {
|
||||||
UUID string `json:"uuid"`
|
UUID string `json:"uuid"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var body Heartbeat
|
var body heartbeat
|
||||||
|
|
||||||
body.UUID = app.runtime.UUID
|
body.UUID = app.context.uuid
|
||||||
body.Version = model.Version
|
body.Version = config.Version
|
||||||
|
|
||||||
bodyJson, err := json.Marshal(body)
|
bodyJson, err := json.Marshal(body)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
app.log.App.Error().Err(err).Msg("Failed to marshal heartbeat body, heartbeat routine will not start")
|
tlog.App.Error().Err(err).Msg("Failed to marshal heartbeat body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,62 +255,45 @@ func (app *BootstrapApp) heartbeatRoutine() {
|
|||||||
Timeout: 30 * time.Second, // The server should never take more than 30 seconds to respond
|
Timeout: 30 * time.Second, // The server should never take more than 30 seconds to respond
|
||||||
}
|
}
|
||||||
|
|
||||||
heartbeatURL := model.APIServer + "/v1/instances/heartbeat"
|
heartbeatURL := config.ApiServer + "/v1/instances/heartbeat"
|
||||||
|
|
||||||
for {
|
for range ticker.C {
|
||||||
select {
|
tlog.App.Debug().Msg("Sending heartbeat")
|
||||||
case <-ticker.C:
|
|
||||||
app.log.App.Debug().Msg("Sending heartbeat")
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodPost, heartbeatURL, bytes.NewReader(bodyJson))
|
req, err := http.NewRequest(http.MethodPost, heartbeatURL, bytes.NewReader(bodyJson))
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
app.log.App.Error().Err(err).Msg("Failed to create heartbeat request")
|
tlog.App.Error().Err(err).Msg("Failed to create heartbeat request")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Add("Content-Type", "application/json")
|
req.Header.Add("Content-Type", "application/json")
|
||||||
|
|
||||||
res, err := client.Do(req)
|
res, err := client.Do(req)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
app.log.App.Error().Err(err).Msg("Failed to send heartbeat")
|
tlog.App.Error().Err(err).Msg("Failed to send heartbeat")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
res.Body.Close()
|
res.Body.Close()
|
||||||
|
|
||||||
if res.StatusCode != 200 && res.StatusCode != 201 {
|
if res.StatusCode != 200 && res.StatusCode != 201 {
|
||||||
app.log.App.Debug().Str("status", res.Status).Msg("Heartbeat returned non-200/201 status")
|
tlog.App.Debug().Str("status", res.Status).Msg("Heartbeat returned non-200/201 status")
|
||||||
}
|
|
||||||
case <-app.ctx.Done():
|
|
||||||
app.log.App.Debug().Msg("Stopping heartbeat routine")
|
|
||||||
ticker.Stop()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *BootstrapApp) dbCleanupRoutine() {
|
func (app *BootstrapApp) dbCleanupRoutine(queries *repository.Queries) {
|
||||||
ticker := time.NewTicker(time.Duration(30) * time.Minute)
|
ticker := time.NewTicker(time.Duration(30) * time.Minute)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
for {
|
for range ticker.C {
|
||||||
select {
|
tlog.App.Debug().Msg("Cleaning up old database sessions")
|
||||||
case <-ticker.C:
|
err := queries.DeleteExpiredSessions(ctx, time.Now().Unix())
|
||||||
app.log.App.Debug().Msg("Running database cleanup")
|
if err != nil {
|
||||||
|
tlog.App.Error().Err(err).Msg("Failed to clean up old database sessions")
|
||||||
err := app.queries.DeleteExpiredSessions(app.ctx, time.Now().Unix())
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
app.log.App.Error().Err(err).Msg("Failed to delete expired sessions")
|
|
||||||
}
|
|
||||||
|
|
||||||
app.log.App.Debug().Msg("Database cleanup completed")
|
|
||||||
case <-app.ctx.Done():
|
|
||||||
app.log.App.Debug().Msg("Stopping database cleanup routine")
|
|
||||||
ticker.Stop()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/assets"
|
"github.com/steveiliop56/tinyauth/internal/assets"
|
||||||
|
|
||||||
"github.com/golang-migrate/migrate/v4"
|
"github.com/golang-migrate/migrate/v4"
|
||||||
"github.com/golang-migrate/migrate/v4/database/sqlite3"
|
"github.com/golang-migrate/migrate/v4/database/sqlite3"
|
||||||
@@ -14,17 +14,17 @@ import (
|
|||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (app *BootstrapApp) SetupDatabase() error {
|
func (app *BootstrapApp) SetupDatabase(databasePath string) (*sql.DB, error) {
|
||||||
dir := filepath.Dir(app.config.Database.Path)
|
dir := filepath.Dir(databasePath)
|
||||||
|
|
||||||
if err := os.MkdirAll(dir, 0750); err != nil {
|
if err := os.MkdirAll(dir, 0750); err != nil {
|
||||||
return fmt.Errorf("failed to create database directory %s: %w", dir, err)
|
return nil, fmt.Errorf("failed to create database directory %s: %w", dir, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := sql.Open("sqlite", app.config.Database.Path)
|
db, err := sql.Open("sqlite", databasePath)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to open database: %w", err)
|
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limit to 1 connection to sequence writes, this may need to be revisited in the future
|
// Limit to 1 connection to sequence writes, this may need to be revisited in the future
|
||||||
@@ -34,29 +34,24 @@ func (app *BootstrapApp) SetupDatabase() error {
|
|||||||
migrations, err := iofs.New(assets.Migrations, "migrations")
|
migrations, err := iofs.New(assets.Migrations, "migrations")
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create migrations: %w", err)
|
return nil, fmt.Errorf("failed to create migrations: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
target, err := sqlite3.WithInstance(db, &sqlite3.Config{})
|
target, err := sqlite3.WithInstance(db, &sqlite3.Config{})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create sqlite3 instance: %w", err)
|
return nil, fmt.Errorf("failed to create sqlite3 instance: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
migrator, err := migrate.NewWithInstance("iofs", migrations, "sqlite3", target)
|
migrator, err := migrate.NewWithInstance("iofs", migrations, "sqlite3", target)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create migrator: %w", err)
|
return nil, fmt.Errorf("failed to create migrator: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := migrator.Up(); err != nil && err != migrate.ErrNoChange {
|
if err := migrator.Up(); err != nil && err != migrate.ErrNoChange {
|
||||||
return fmt.Errorf("failed to migrate database: %w", err)
|
return nil, fmt.Errorf("failed to migrate database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
app.db = db
|
return db, nil
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *BootstrapApp) GetDB() *sql.DB {
|
|
||||||
return app.db
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,21 @@ package bootstrap
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/controller"
|
"github.com/steveiliop56/tinyauth/internal/config"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/middleware"
|
"github.com/steveiliop56/tinyauth/internal/controller"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/middleware"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (app *BootstrapApp) setupRouter() error {
|
var DEV_MODES = []string{"main", "test", "development"}
|
||||||
// we don't want gin debug mode
|
|
||||||
gin.SetMode(gin.ReleaseMode)
|
func (app *BootstrapApp) setupRouter() (*gin.Engine, error) {
|
||||||
|
if !slices.Contains(DEV_MODES, config.Version) {
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
}
|
||||||
|
|
||||||
engine := gin.New()
|
engine := gin.New()
|
||||||
engine.Use(gin.Recovery())
|
engine.Use(gin.Recovery())
|
||||||
@@ -20,36 +25,98 @@ func (app *BootstrapApp) setupRouter() error {
|
|||||||
err := engine.SetTrustedProxies(app.config.Auth.TrustedProxies)
|
err := engine.SetTrustedProxies(app.config.Auth.TrustedProxies)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to set trusted proxies: %w", err)
|
return nil, fmt.Errorf("failed to set trusted proxies: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
contextMiddleware := middleware.NewContextMiddleware(app.log, app.runtime, app.services.authService, app.services.oauthBrokerService)
|
contextMiddleware := middleware.NewContextMiddleware(middleware.ContextMiddlewareConfig{
|
||||||
engine.Use(contextMiddleware.Middleware())
|
CookieDomain: app.context.cookieDomain,
|
||||||
|
}, app.services.authService, app.services.oauthBrokerService)
|
||||||
|
|
||||||
uiMiddleware, err := middleware.NewUIMiddleware()
|
err := contextMiddleware.Init()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to initialize UI middleware: %w", err)
|
return nil, fmt.Errorf("failed to initialize context middleware: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.Use(contextMiddleware.Middleware())
|
||||||
|
|
||||||
|
uiMiddleware := middleware.NewUIMiddleware()
|
||||||
|
|
||||||
|
err = uiMiddleware.Init()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to initialize UI middleware: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
engine.Use(uiMiddleware.Middleware())
|
engine.Use(uiMiddleware.Middleware())
|
||||||
|
|
||||||
zerologMiddleware := middleware.NewZerologMiddleware(app.log)
|
zerologMiddleware := middleware.NewZerologMiddleware()
|
||||||
|
|
||||||
|
err = zerologMiddleware.Init()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to initialize zerolog middleware: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
engine.Use(zerologMiddleware.Middleware())
|
engine.Use(zerologMiddleware.Middleware())
|
||||||
|
|
||||||
apiRouter := engine.Group("/api")
|
apiRouter := engine.Group("/api")
|
||||||
|
|
||||||
controller.NewContextController(app.log, app.config, app.runtime, apiRouter)
|
contextController := controller.NewContextController(controller.ContextControllerConfig{
|
||||||
controller.NewOAuthController(app.log, app.config, app.runtime, apiRouter, app.services.authService)
|
Providers: app.context.configuredProviders,
|
||||||
controller.NewOIDCController(app.log, app.services.oidcService, apiRouter)
|
Title: app.config.UI.Title,
|
||||||
controller.NewProxyController(app.log, app.runtime, apiRouter, app.services.accessControlService, app.services.authService)
|
AppURL: app.config.AppURL,
|
||||||
controller.NewUserController(app.log, app.runtime, apiRouter, app.services.authService)
|
CookieDomain: app.context.cookieDomain,
|
||||||
controller.NewResourcesController(app.config, &engine.RouterGroup)
|
ForgotPasswordMessage: app.config.UI.ForgotPasswordMessage,
|
||||||
controller.NewHealthController(apiRouter)
|
BackgroundImage: app.config.UI.BackgroundImage,
|
||||||
controller.NewWellKnownController(app.services.oidcService, &engine.RouterGroup)
|
OAuthAutoRedirect: app.config.OAuth.AutoRedirect,
|
||||||
|
WarningsEnabled: app.config.UI.WarningsEnabled,
|
||||||
|
}, apiRouter)
|
||||||
|
|
||||||
app.router = engine
|
contextController.SetupRoutes()
|
||||||
return nil
|
|
||||||
|
oauthController := controller.NewOAuthController(controller.OAuthControllerConfig{
|
||||||
|
AppURL: app.config.AppURL,
|
||||||
|
SecureCookie: app.config.Auth.SecureCookie,
|
||||||
|
CSRFCookieName: app.context.csrfCookieName,
|
||||||
|
RedirectCookieName: app.context.redirectCookieName,
|
||||||
|
CookieDomain: app.context.cookieDomain,
|
||||||
|
OAuthSessionCookieName: app.context.oauthSessionCookieName,
|
||||||
|
}, apiRouter, app.services.authService)
|
||||||
|
|
||||||
|
oauthController.SetupRoutes()
|
||||||
|
|
||||||
|
oidcController := controller.NewOIDCController(controller.OIDCControllerConfig{}, app.services.oidcService, apiRouter)
|
||||||
|
|
||||||
|
oidcController.SetupRoutes()
|
||||||
|
|
||||||
|
proxyController := controller.NewProxyController(controller.ProxyControllerConfig{
|
||||||
|
AppURL: app.config.AppURL,
|
||||||
|
}, apiRouter, app.services.accessControlService, app.services.authService)
|
||||||
|
|
||||||
|
proxyController.SetupRoutes()
|
||||||
|
|
||||||
|
userController := controller.NewUserController(controller.UserControllerConfig{
|
||||||
|
CookieDomain: app.context.cookieDomain,
|
||||||
|
}, apiRouter, app.services.authService)
|
||||||
|
|
||||||
|
userController.SetupRoutes()
|
||||||
|
|
||||||
|
resourcesController := controller.NewResourcesController(controller.ResourcesControllerConfig{
|
||||||
|
Path: app.config.Resources.Path,
|
||||||
|
Enabled: app.config.Resources.Enabled,
|
||||||
|
}, &engine.RouterGroup)
|
||||||
|
|
||||||
|
resourcesController.SetupRoutes()
|
||||||
|
|
||||||
|
healthController := controller.NewHealthController(apiRouter)
|
||||||
|
|
||||||
|
healthController.SetupRoutes()
|
||||||
|
|
||||||
|
wellknownController := controller.NewWellKnownController(controller.WellKnownControllerConfig{}, app.services.oidcService, engine)
|
||||||
|
|
||||||
|
wellknownController.SetupRoutes()
|
||||||
|
|
||||||
|
return engine, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,66 +1,110 @@
|
|||||||
package bootstrap
|
package bootstrap
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"github.com/steveiliop56/tinyauth/internal/repository"
|
||||||
"os"
|
"github.com/steveiliop56/tinyauth/internal/service"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (app *BootstrapApp) setupServices() error {
|
type Services struct {
|
||||||
ldapService, err := service.NewLdapService(app.log, app.config, app.ctx, &app.wg)
|
accessControlService *service.AccessControlsService
|
||||||
|
authService *service.AuthService
|
||||||
if err != nil {
|
dockerService *service.DockerService
|
||||||
app.log.App.Warn().Err(err).Msg("Failed to initialize LDAP connection, will continue without it")
|
ldapService *service.LdapService
|
||||||
}
|
oauthBrokerService *service.OAuthBrokerService
|
||||||
|
oidcService *service.OIDCService
|
||||||
app.services.ldapService = ldapService
|
}
|
||||||
|
|
||||||
useKubernetes := app.config.LabelProvider == "kubernetes" ||
|
func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, error) {
|
||||||
(app.config.LabelProvider == "auto" && os.Getenv("KUBERNETES_SERVICE_HOST") != "")
|
services := Services{}
|
||||||
|
|
||||||
var labelProvider service.LabelProvider
|
ldapService := service.NewLdapService(service.LdapServiceConfig{
|
||||||
|
Address: app.config.Ldap.Address,
|
||||||
if useKubernetes {
|
BindDN: app.config.Ldap.BindDN,
|
||||||
app.log.App.Debug().Msg("Using Kubernetes label provider")
|
BindPassword: app.config.Ldap.BindPassword,
|
||||||
|
BaseDN: app.config.Ldap.BaseDN,
|
||||||
kubernetesService, err := service.NewKubernetesService(app.log, app.ctx, &app.wg)
|
Insecure: app.config.Ldap.Insecure,
|
||||||
|
SearchFilter: app.config.Ldap.SearchFilter,
|
||||||
if err != nil {
|
AuthCert: app.config.Ldap.AuthCert,
|
||||||
return fmt.Errorf("failed to initialize kubernetes service: %w", err)
|
AuthKey: app.config.Ldap.AuthKey,
|
||||||
}
|
})
|
||||||
|
|
||||||
app.services.kubernetesService = kubernetesService
|
err := ldapService.Init()
|
||||||
labelProvider = kubernetesService
|
|
||||||
} else {
|
if err != nil {
|
||||||
app.log.App.Debug().Msg("Using Docker label provider")
|
tlog.App.Warn().Err(err).Msg("Failed to setup LDAP service, starting without it")
|
||||||
|
ldapService.Unconfigure()
|
||||||
dockerService, err := service.NewDockerService(app.log, app.ctx, &app.wg)
|
}
|
||||||
|
|
||||||
if err != nil {
|
services.ldapService = ldapService
|
||||||
return fmt.Errorf("failed to initialize docker service: %w", err)
|
|
||||||
}
|
dockerService := service.NewDockerService()
|
||||||
|
|
||||||
app.services.dockerService = dockerService
|
err = dockerService.Init()
|
||||||
labelProvider = dockerService
|
|
||||||
}
|
if err != nil {
|
||||||
|
return Services{}, err
|
||||||
accessControlsService := service.NewAccessControlsService(app.log, &labelProvider, app.config.Apps)
|
}
|
||||||
app.services.accessControlService = accessControlsService
|
|
||||||
|
services.dockerService = dockerService
|
||||||
oauthBrokerService := service.NewOAuthBrokerService(app.log, app.runtime.OAuthProviders, app.ctx)
|
|
||||||
app.services.oauthBrokerService = oauthBrokerService
|
accessControlsService := service.NewAccessControlsService(dockerService, app.config.Apps)
|
||||||
|
|
||||||
authService := service.NewAuthService(app.log, app.config, app.runtime, app.ctx, &app.wg, app.services.ldapService, app.queries, app.services.oauthBrokerService)
|
err = accessControlsService.Init()
|
||||||
app.services.authService = authService
|
|
||||||
|
if err != nil {
|
||||||
oidcService, err := service.NewOIDCService(app.log, app.config, app.runtime, app.queries, app.ctx, &app.wg)
|
return Services{}, err
|
||||||
|
}
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to initialize oidc service: %w", err)
|
services.accessControlService = accessControlsService
|
||||||
}
|
|
||||||
|
oauthBrokerService := service.NewOAuthBrokerService(app.context.oauthProviders)
|
||||||
app.services.oidcService = oidcService
|
|
||||||
|
err = oauthBrokerService.Init()
|
||||||
return nil
|
|
||||||
|
if err != nil {
|
||||||
|
return Services{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
services.oauthBrokerService = oauthBrokerService
|
||||||
|
|
||||||
|
authService := service.NewAuthService(service.AuthServiceConfig{
|
||||||
|
Users: app.context.users,
|
||||||
|
OauthWhitelist: app.config.OAuth.Whitelist,
|
||||||
|
SessionExpiry: app.config.Auth.SessionExpiry,
|
||||||
|
SessionMaxLifetime: app.config.Auth.SessionMaxLifetime,
|
||||||
|
SecureCookie: app.config.Auth.SecureCookie,
|
||||||
|
CookieDomain: app.context.cookieDomain,
|
||||||
|
LoginTimeout: app.config.Auth.LoginTimeout,
|
||||||
|
LoginMaxRetries: app.config.Auth.LoginMaxRetries,
|
||||||
|
SessionCookieName: app.context.sessionCookieName,
|
||||||
|
IP: app.config.Auth.IP,
|
||||||
|
LDAPGroupsCacheTTL: app.config.Ldap.GroupCacheTTL,
|
||||||
|
}, dockerService, services.ldapService, queries, services.oauthBrokerService)
|
||||||
|
|
||||||
|
err = authService.Init()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return Services{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
services.authService = authService
|
||||||
|
|
||||||
|
oidcService := service.NewOIDCService(service.OIDCServiceConfig{
|
||||||
|
Clients: app.config.OIDC.Clients,
|
||||||
|
PrivateKeyPath: app.config.OIDC.PrivateKeyPath,
|
||||||
|
PublicKeyPath: app.config.OIDC.PublicKeyPath,
|
||||||
|
Issuer: app.config.AppURL,
|
||||||
|
SessionExpiry: app.config.Auth.SessionExpiry,
|
||||||
|
}, queries)
|
||||||
|
|
||||||
|
err = oidcService.Init()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return Services{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
services.oidcService = oidcService
|
||||||
|
|
||||||
|
return services, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package model
|
package config
|
||||||
|
|
||||||
// Default configuration
|
// Default configuration
|
||||||
func NewDefaultConfiguration() *Config {
|
func NewDefaultConfiguration() *Config {
|
||||||
@@ -14,12 +14,10 @@ func NewDefaultConfiguration() *Config {
|
|||||||
Path: "./resources",
|
Path: "./resources",
|
||||||
},
|
},
|
||||||
Server: ServerConfig{
|
Server: ServerConfig{
|
||||||
Port: 3000,
|
Port: 3000,
|
||||||
Address: "0.0.0.0",
|
Address: "0.0.0.0",
|
||||||
ConcurrentListenersEnabled: false,
|
|
||||||
},
|
},
|
||||||
Auth: AuthConfig{
|
Auth: AuthConfig{
|
||||||
SubdomainsEnabled: true,
|
|
||||||
SessionExpiry: 86400, // 1 day
|
SessionExpiry: 86400, // 1 day
|
||||||
SessionMaxLifetime: 0, // disabled
|
SessionMaxLifetime: 0, // disabled
|
||||||
LoginTimeout: 300, // 5 minutes
|
LoginTimeout: 300, // 5 minutes
|
||||||
@@ -31,7 +29,7 @@ func NewDefaultConfiguration() *Config {
|
|||||||
BackgroundImage: "/background.jpg",
|
BackgroundImage: "/background.jpg",
|
||||||
WarningsEnabled: true,
|
WarningsEnabled: true,
|
||||||
},
|
},
|
||||||
LDAP: LDAPConfig{
|
Ldap: LdapConfig{
|
||||||
Insecure: false,
|
Insecure: false,
|
||||||
SearchFilter: "(uid=%s)",
|
SearchFilter: "(uid=%s)",
|
||||||
GroupCacheTTL: 900, // 15 minutes
|
GroupCacheTTL: 900, // 15 minutes
|
||||||
@@ -61,25 +59,38 @@ func NewDefaultConfiguration() *Config {
|
|||||||
Experimental: ExperimentalConfig{
|
Experimental: ExperimentalConfig{
|
||||||
ConfigFile: "",
|
ConfigFile: "",
|
||||||
},
|
},
|
||||||
LabelProvider: "auto",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Version information, set at build time
|
||||||
|
|
||||||
|
var Version = "development"
|
||||||
|
var CommitHash = "development"
|
||||||
|
var BuildTimestamp = "0000-00-00T00:00:00Z"
|
||||||
|
|
||||||
|
// Cookie name templates
|
||||||
|
|
||||||
|
var SessionCookieName = "tinyauth-session"
|
||||||
|
var CSRFCookieName = "tinyauth-csrf"
|
||||||
|
var RedirectCookieName = "tinyauth-redirect"
|
||||||
|
var OAuthSessionCookieName = "tinyauth-oauth"
|
||||||
|
|
||||||
|
// Main app config
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
AppURL string `description:"The base URL where the app is hosted." yaml:"appUrl"`
|
AppURL string `description:"The base URL where the app is hosted." yaml:"appUrl"`
|
||||||
Database DatabaseConfig `description:"Database configuration." yaml:"database"`
|
Database DatabaseConfig `description:"Database configuration." yaml:"database"`
|
||||||
Analytics AnalyticsConfig `description:"Analytics configuration." yaml:"analytics"`
|
Analytics AnalyticsConfig `description:"Analytics configuration." yaml:"analytics"`
|
||||||
Resources ResourcesConfig `description:"Resources configuration." yaml:"resources"`
|
Resources ResourcesConfig `description:"Resources configuration." yaml:"resources"`
|
||||||
Server ServerConfig `description:"Server configuration." yaml:"server"`
|
Server ServerConfig `description:"Server configuration." yaml:"server"`
|
||||||
Auth AuthConfig `description:"Authentication configuration." yaml:"auth"`
|
Auth AuthConfig `description:"Authentication configuration." yaml:"auth"`
|
||||||
Apps map[string]App `description:"Application ACLs configuration." yaml:"apps"`
|
Apps map[string]App `description:"Application ACLs configuration." yaml:"apps"`
|
||||||
OAuth OAuthConfig `description:"OAuth configuration." yaml:"oauth"`
|
OAuth OAuthConfig `description:"OAuth configuration." yaml:"oauth"`
|
||||||
OIDC OIDCConfig `description:"OIDC configuration." yaml:"oidc"`
|
OIDC OIDCConfig `description:"OIDC configuration." yaml:"oidc"`
|
||||||
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, or kubernetes). auto detects the environment." yaml:"labelProvider"`
|
Log LogConfig `description:"Logging configuration." yaml:"log"`
|
||||||
Log LogConfig `description:"Logging configuration." yaml:"log"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type DatabaseConfig struct {
|
type DatabaseConfig struct {
|
||||||
@@ -96,51 +107,21 @@ type ResourcesConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
Port int `description:"The port on which the server listens." yaml:"port"`
|
Port int `description:"The port on which the server listens." yaml:"port"`
|
||||||
Address string `description:"The address on which the server listens." yaml:"address"`
|
Address string `description:"The address on which the server listens." yaml:"address"`
|
||||||
SocketPath string `description:"The path to the Unix socket." yaml:"socketPath"`
|
SocketPath string `description:"The path to the Unix socket." yaml:"socketPath"`
|
||||||
ConcurrentListenersEnabled bool `description:"Enable listening on both TCP and Unix socket at the same time." yaml:"concurrentListenersEnabled"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthConfig struct {
|
type AuthConfig struct {
|
||||||
IP IPConfig `description:"IP whitelisting config options." yaml:"ip"`
|
IP IPConfig `description:"IP whitelisting config options." yaml:"ip"`
|
||||||
Users []string `description:"Comma-separated list of users (username:hashed_password)." yaml:"users"`
|
Users []string `description:"Comma-separated list of users (username:hashed_password)." yaml:"users"`
|
||||||
SubdomainsEnabled bool `description:"Enable subdomains support." yaml:"subdomainsEnabled"`
|
UsersFile string `description:"Path to the users file." yaml:"usersFile"`
|
||||||
UserAttributes map[string]UserAttributes `description:"Map of per-user OIDC attributes (username -> attributes)." yaml:"userAttributes"`
|
SecureCookie bool `description:"Enable secure cookies." yaml:"secureCookie"`
|
||||||
UsersFile string `description:"Path to the users file." yaml:"usersFile"`
|
SessionExpiry int `description:"Session expiry time in seconds." yaml:"sessionExpiry"`
|
||||||
SecureCookie bool `description:"Enable secure cookies." yaml:"secureCookie"`
|
SessionMaxLifetime int `description:"Maximum session lifetime in seconds." yaml:"sessionMaxLifetime"`
|
||||||
SessionExpiry int `description:"Session expiry time in seconds." yaml:"sessionExpiry"`
|
LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"`
|
||||||
SessionMaxLifetime int `description:"Maximum session lifetime in seconds." yaml:"sessionMaxLifetime"`
|
LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"`
|
||||||
LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"`
|
TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies"`
|
||||||
LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"`
|
|
||||||
TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserAttributes struct {
|
|
||||||
Name string `description:"Full name of the user." yaml:"name"`
|
|
||||||
GivenName string `description:"Given (first) name of the user." yaml:"givenName"`
|
|
||||||
FamilyName string `description:"Family (last) name of the user." yaml:"familyName"`
|
|
||||||
MiddleName string `description:"Middle name of the user." yaml:"middleName"`
|
|
||||||
Nickname string `description:"Nickname of the user." yaml:"nickname"`
|
|
||||||
Profile string `description:"URL of the user's profile page." yaml:"profile"`
|
|
||||||
Picture string `description:"URL of the user's profile picture." yaml:"picture"`
|
|
||||||
Website string `description:"URL of the user's website." yaml:"website"`
|
|
||||||
Email string `description:"Email address of the user." yaml:"email"`
|
|
||||||
Gender string `description:"Gender of the user." yaml:"gender"`
|
|
||||||
Birthdate string `description:"Birthdate of the user (YYYY-MM-DD)." yaml:"birthdate"`
|
|
||||||
Zoneinfo string `description:"Time zone of the user (e.g. Europe/Athens)." yaml:"zoneinfo"`
|
|
||||||
Locale string `description:"Locale of the user (e.g. en-US)." yaml:"locale"`
|
|
||||||
PhoneNumber string `description:"Phone number of the user." yaml:"phoneNumber"`
|
|
||||||
Address AddressClaim `description:"Address of the user." yaml:"address"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AddressClaim struct {
|
|
||||||
Formatted string `description:"Full mailing address, formatted for display." yaml:"formatted" json:"formatted,omitempty"`
|
|
||||||
StreetAddress string `description:"Street address." yaml:"streetAddress" json:"street_address,omitempty"`
|
|
||||||
Locality string `description:"City or locality." yaml:"locality" json:"locality,omitempty"`
|
|
||||||
Region string `description:"State, province, or region." yaml:"region" json:"region,omitempty"`
|
|
||||||
PostalCode string `description:"Zip or postal code." yaml:"postalCode" json:"postal_code,omitempty"`
|
|
||||||
Country string `description:"Country." yaml:"country" json:"country,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type IPConfig struct {
|
type IPConfig struct {
|
||||||
@@ -149,10 +130,9 @@ type IPConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OAuthConfig struct {
|
type OAuthConfig struct {
|
||||||
Whitelist []string `description:"Comma-separated list of allowed OAuth domains." yaml:"whitelist"`
|
Whitelist []string `description:"Comma-separated list of allowed OAuth domains." yaml:"whitelist"`
|
||||||
WhitelistFile string `description:"Path to the OAuth whitelist file." yaml:"whitelistFile"`
|
AutoRedirect string `description:"The OAuth provider to use for automatic redirection." yaml:"autoRedirect"`
|
||||||
AutoRedirect string `description:"The OAuth provider to use for automatic redirection." yaml:"autoRedirect"`
|
Providers map[string]OAuthServiceConfig `description:"OAuth providers configuration." yaml:"providers"`
|
||||||
Providers map[string]OAuthServiceConfig `description:"OAuth providers configuration." yaml:"providers"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type OIDCConfig struct {
|
type OIDCConfig struct {
|
||||||
@@ -168,7 +148,7 @@ type UIConfig struct {
|
|||||||
WarningsEnabled bool `description:"Enable UI warnings." yaml:"warningsEnabled"`
|
WarningsEnabled bool `description:"Enable UI warnings." yaml:"warningsEnabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LDAPConfig struct {
|
type LdapConfig struct {
|
||||||
Address string `description:"LDAP server address." yaml:"address"`
|
Address string `description:"LDAP server address." yaml:"address"`
|
||||||
BindDN string `description:"Bind DN for LDAP authentication." yaml:"bindDn"`
|
BindDN string `description:"Bind DN for LDAP authentication." yaml:"bindDn"`
|
||||||
BindPassword string `description:"Bind password for LDAP authentication." yaml:"bindPassword"`
|
BindPassword string `description:"Bind password for LDAP authentication." yaml:"bindPassword"`
|
||||||
@@ -201,6 +181,20 @@ type ExperimentalConfig struct {
|
|||||||
ConfigFile string `description:"Path to config file." yaml:"-"`
|
ConfigFile string `description:"Path to config file." yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Config loader options
|
||||||
|
|
||||||
|
const DefaultNamePrefix = "TINYAUTH_"
|
||||||
|
|
||||||
|
// OAuth/OIDC config
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
Sub string `json:"sub"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
PreferredUsername string `json:"preferred_username"`
|
||||||
|
Groups any `json:"groups"`
|
||||||
|
}
|
||||||
|
|
||||||
type OAuthServiceConfig struct {
|
type OAuthServiceConfig struct {
|
||||||
ClientID string `description:"OAuth client ID." yaml:"clientId"`
|
ClientID string `description:"OAuth client ID." yaml:"clientId"`
|
||||||
ClientSecret string `description:"OAuth client secret." yaml:"clientSecret"`
|
ClientSecret string `description:"OAuth client secret." yaml:"clientSecret"`
|
||||||
@@ -223,6 +217,58 @@ type OIDCClientConfig struct {
|
|||||||
Name string `description:"Client name in UI." yaml:"name"`
|
Name string `description:"Client name in UI." yaml:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var OverrideProviders = map[string]string{
|
||||||
|
"google": "Google",
|
||||||
|
"github": "GitHub",
|
||||||
|
}
|
||||||
|
|
||||||
|
// User/session related stuff
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
TotpSecret string
|
||||||
|
}
|
||||||
|
|
||||||
|
type LdapUser struct {
|
||||||
|
DN string
|
||||||
|
Groups []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserSearch struct {
|
||||||
|
Username string
|
||||||
|
Type string // local, ldap or unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserContext struct {
|
||||||
|
Username string
|
||||||
|
Name string
|
||||||
|
Email string
|
||||||
|
IsLoggedIn bool
|
||||||
|
IsBasicAuth bool
|
||||||
|
OAuth bool
|
||||||
|
Provider string
|
||||||
|
TotpPending bool
|
||||||
|
OAuthGroups string
|
||||||
|
TotpEnabled bool
|
||||||
|
OAuthName string
|
||||||
|
OAuthSub string
|
||||||
|
LdapGroups string
|
||||||
|
}
|
||||||
|
|
||||||
|
// API responses and queries
|
||||||
|
|
||||||
|
type UnauthorizedQuery struct {
|
||||||
|
Username string `url:"username"`
|
||||||
|
Resource string `url:"resource"`
|
||||||
|
GroupErr bool `url:"groupErr"`
|
||||||
|
IP string `url:"ip"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RedirectQuery struct {
|
||||||
|
RedirectURI string `url:"redirect_uri"`
|
||||||
|
}
|
||||||
|
|
||||||
// ACLs
|
// ACLs
|
||||||
|
|
||||||
type Apps struct {
|
type Apps struct {
|
||||||
@@ -278,3 +324,7 @@ type AppPath struct {
|
|||||||
Allow string `description:"Comma-separated list of allowed paths." yaml:"allow"`
|
Allow string `description:"Comma-separated list of allowed paths." yaml:"allow"`
|
||||||
Block string `description:"Comma-separated list of blocked paths." yaml:"block"`
|
Block string `description:"Comma-separated list of blocked paths." yaml:"block"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// API server
|
||||||
|
|
||||||
|
var ApiServer = "https://api.tinyauth.app"
|
||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -19,86 +19,94 @@ type UserContextResponse struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
OAuth bool `json:"oauth"`
|
OAuth bool `json:"oauth"`
|
||||||
TOTPPending bool `json:"totpPending"`
|
TotpPending bool `json:"totpPending"`
|
||||||
OAuthName string `json:"oauthName"`
|
OAuthName string `json:"oauthName"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AppContextResponse struct {
|
type AppContextResponse struct {
|
||||||
Status int `json:"status"`
|
Status int `json:"status"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Providers []model.Provider `json:"providers"`
|
Providers []Provider `json:"providers"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
AppURL string `json:"appUrl"`
|
AppURL string `json:"appUrl"`
|
||||||
CookieDomain string `json:"cookieDomain"`
|
CookieDomain string `json:"cookieDomain"`
|
||||||
ForgotPasswordMessage string `json:"forgotPasswordMessage"`
|
ForgotPasswordMessage string `json:"forgotPasswordMessage"`
|
||||||
BackgroundImage string `json:"backgroundImage"`
|
BackgroundImage string `json:"backgroundImage"`
|
||||||
OAuthAutoRedirect string `json:"oauthAutoRedirect"`
|
OAuthAutoRedirect string `json:"oauthAutoRedirect"`
|
||||||
WarningsEnabled bool `json:"warningsEnabled"`
|
WarningsEnabled bool `json:"warningsEnabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Provider struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
OAuth bool `json:"oauth"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContextControllerConfig struct {
|
||||||
|
Providers []Provider
|
||||||
|
Title string
|
||||||
|
AppURL string
|
||||||
|
CookieDomain string
|
||||||
|
ForgotPasswordMessage string
|
||||||
|
BackgroundImage string
|
||||||
|
OAuthAutoRedirect string
|
||||||
|
WarningsEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type ContextController struct {
|
type ContextController struct {
|
||||||
log *logger.Logger
|
config ContextControllerConfig
|
||||||
config model.Config
|
router *gin.RouterGroup
|
||||||
runtime model.RuntimeConfig
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewContextController(
|
func NewContextController(config ContextControllerConfig, router *gin.RouterGroup) *ContextController {
|
||||||
log *logger.Logger,
|
if !config.WarningsEnabled {
|
||||||
config model.Config,
|
tlog.App.Warn().Msg("UI warnings are disabled. This may expose users to security risks. Proceed with caution.")
|
||||||
runtimeConfig model.RuntimeConfig,
|
|
||||||
router *gin.RouterGroup,
|
|
||||||
) *ContextController {
|
|
||||||
controller := &ContextController{
|
|
||||||
log: log,
|
|
||||||
config: config,
|
|
||||||
runtime: runtimeConfig,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !config.UI.WarningsEnabled {
|
return &ContextController{
|
||||||
log.App.Warn().Msg("UI warnings are disabled. This may lead to security issues if you are not careful. Make sure to enable warnings in production environments.")
|
config: config,
|
||||||
|
router: router,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
contextGroup := router.Group("/context")
|
func (controller *ContextController) SetupRoutes() {
|
||||||
|
contextGroup := controller.router.Group("/context")
|
||||||
contextGroup.GET("/user", controller.userContextHandler)
|
contextGroup.GET("/user", controller.userContextHandler)
|
||||||
contextGroup.GET("/app", controller.appContextHandler)
|
contextGroup.GET("/app", controller.appContextHandler)
|
||||||
|
|
||||||
return controller
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (controller *ContextController) userContextHandler(c *gin.Context) {
|
func (controller *ContextController) userContextHandler(c *gin.Context) {
|
||||||
context, err := new(model.UserContext).NewFromGin(c)
|
context, err := utils.GetContext(c)
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to create user context from request")
|
|
||||||
c.JSON(200, UserContextResponse{
|
|
||||||
Status: 401,
|
|
||||||
Message: "Unauthorized",
|
|
||||||
IsLoggedIn: false,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userContext := UserContextResponse{
|
userContext := UserContextResponse{
|
||||||
Status: 200,
|
Status: 200,
|
||||||
Message: "Success",
|
Message: "Success",
|
||||||
IsLoggedIn: context.Authenticated,
|
IsLoggedIn: context.IsLoggedIn,
|
||||||
Username: context.GetUsername(),
|
Username: context.Username,
|
||||||
Name: context.GetName(),
|
Name: context.Name,
|
||||||
Email: context.GetEmail(),
|
Email: context.Email,
|
||||||
Provider: context.GetProviderID(),
|
Provider: context.Provider,
|
||||||
OAuth: context.IsOAuth(),
|
OAuth: context.OAuth,
|
||||||
TOTPPending: context.TOTPPending(),
|
TotpPending: context.TotpPending,
|
||||||
OAuthName: context.OAuthName(),
|
OAuthName: context.OAuthName,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
tlog.App.Debug().Err(err).Msg("No user context found in request")
|
||||||
|
userContext.Status = 401
|
||||||
|
userContext.Message = "Unauthorized"
|
||||||
|
userContext.IsLoggedIn = false
|
||||||
|
c.JSON(200, userContext)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(200, userContext)
|
c.JSON(200, userContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (controller *ContextController) appContextHandler(c *gin.Context) {
|
func (controller *ContextController) appContextHandler(c *gin.Context) {
|
||||||
appUrl, err := url.Parse(controller.runtime.AppURL)
|
appUrl, err := url.Parse(controller.config.AppURL)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to parse app URL")
|
tlog.App.Error().Err(err).Msg("Failed to parse app URL")
|
||||||
c.JSON(500, gin.H{
|
c.JSON(500, gin.H{
|
||||||
"status": 500,
|
"status": 500,
|
||||||
"message": "Internal Server Error",
|
"message": "Internal Server Error",
|
||||||
@@ -109,13 +117,13 @@ func (controller *ContextController) appContextHandler(c *gin.Context) {
|
|||||||
c.JSON(200, AppContextResponse{
|
c.JSON(200, AppContextResponse{
|
||||||
Status: 200,
|
Status: 200,
|
||||||
Message: "Success",
|
Message: "Success",
|
||||||
Providers: controller.runtime.ConfiguredProviders,
|
Providers: controller.config.Providers,
|
||||||
Title: controller.config.UI.Title,
|
Title: controller.config.Title,
|
||||||
AppURL: fmt.Sprintf("%s://%s", appUrl.Scheme, appUrl.Host),
|
AppURL: fmt.Sprintf("%s://%s", appUrl.Scheme, appUrl.Host),
|
||||||
CookieDomain: controller.runtime.CookieDomain,
|
CookieDomain: controller.config.CookieDomain,
|
||||||
ForgotPasswordMessage: controller.config.UI.ForgotPasswordMessage,
|
ForgotPasswordMessage: controller.config.ForgotPasswordMessage,
|
||||||
BackgroundImage: controller.config.UI.BackgroundImage,
|
BackgroundImage: controller.config.BackgroundImage,
|
||||||
OAuthAutoRedirect: controller.config.OAuth.AutoRedirect,
|
OAuthAutoRedirect: controller.config.OAuthAutoRedirect,
|
||||||
WarningsEnabled: controller.config.UI.WarningsEnabled,
|
WarningsEnabled: controller.config.WarningsEnabled,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,19 +7,31 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/config"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/controller"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/controller"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestContextController(t *testing.T) {
|
func TestContextController(t *testing.T) {
|
||||||
log := logger.NewLogger().WithTestConfig()
|
tlog.NewTestLogger().Init()
|
||||||
log.Init()
|
controllerConfig := controller.ContextControllerConfig{
|
||||||
|
Providers: []controller.Provider{
|
||||||
cfg, runtime := createTestConfigs(t)
|
{
|
||||||
|
Name: "Local",
|
||||||
|
ID: "local",
|
||||||
|
OAuth: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Title: "Tinyauth",
|
||||||
|
AppURL: "https://tinyauth.example.com",
|
||||||
|
CookieDomain: "example.com",
|
||||||
|
ForgotPasswordMessage: "foo",
|
||||||
|
BackgroundImage: "/background.jpg",
|
||||||
|
OAuthAutoRedirect: "none",
|
||||||
|
WarningsEnabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
description string
|
description string
|
||||||
@@ -35,17 +47,17 @@ func TestContextController(t *testing.T) {
|
|||||||
expectedAppContextResponse := controller.AppContextResponse{
|
expectedAppContextResponse := controller.AppContextResponse{
|
||||||
Status: 200,
|
Status: 200,
|
||||||
Message: "Success",
|
Message: "Success",
|
||||||
Providers: runtime.ConfiguredProviders,
|
Providers: controllerConfig.Providers,
|
||||||
Title: cfg.UI.Title,
|
Title: controllerConfig.Title,
|
||||||
AppURL: runtime.AppURL,
|
AppURL: controllerConfig.AppURL,
|
||||||
CookieDomain: runtime.CookieDomain,
|
CookieDomain: controllerConfig.CookieDomain,
|
||||||
ForgotPasswordMessage: cfg.UI.ForgotPasswordMessage,
|
ForgotPasswordMessage: controllerConfig.ForgotPasswordMessage,
|
||||||
BackgroundImage: cfg.UI.BackgroundImage,
|
BackgroundImage: controllerConfig.BackgroundImage,
|
||||||
OAuthAutoRedirect: cfg.OAuth.AutoRedirect,
|
OAuthAutoRedirect: controllerConfig.OAuthAutoRedirect,
|
||||||
WarningsEnabled: cfg.UI.WarningsEnabled,
|
WarningsEnabled: controllerConfig.WarningsEnabled,
|
||||||
}
|
}
|
||||||
bytes, err := json.Marshal(expectedAppContextResponse)
|
bytes, err := json.Marshal(expectedAppContextResponse)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
return string(bytes)
|
return string(bytes)
|
||||||
}(),
|
}(),
|
||||||
},
|
},
|
||||||
@@ -59,7 +71,7 @@ func TestContextController(t *testing.T) {
|
|||||||
Message: "Unauthorized",
|
Message: "Unauthorized",
|
||||||
}
|
}
|
||||||
bytes, err := json.Marshal(expectedUserContextResponse)
|
bytes, err := json.Marshal(expectedUserContextResponse)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
return string(bytes)
|
return string(bytes)
|
||||||
}(),
|
}(),
|
||||||
},
|
},
|
||||||
@@ -67,16 +79,12 @@ func TestContextController(t *testing.T) {
|
|||||||
description: "Ensure user context returns when authorized",
|
description: "Ensure user context returns when authorized",
|
||||||
middlewares: []gin.HandlerFunc{
|
middlewares: []gin.HandlerFunc{
|
||||||
func(c *gin.Context) {
|
func(c *gin.Context) {
|
||||||
c.Set("context", &model.UserContext{
|
c.Set("context", &config.UserContext{
|
||||||
Authenticated: true,
|
Username: "johndoe",
|
||||||
Provider: model.ProviderLocal,
|
Name: "John Doe",
|
||||||
Local: &model.LocalContext{
|
Email: utils.CompileUserEmail("johndoe", controllerConfig.CookieDomain),
|
||||||
BaseContext: model.BaseContext{
|
Provider: "local",
|
||||||
Username: "johndoe",
|
IsLoggedIn: true,
|
||||||
Name: "John Doe",
|
|
||||||
Email: utils.CompileUserEmail("johndoe", runtime.CookieDomain),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -88,11 +96,11 @@ func TestContextController(t *testing.T) {
|
|||||||
IsLoggedIn: true,
|
IsLoggedIn: true,
|
||||||
Username: "johndoe",
|
Username: "johndoe",
|
||||||
Name: "John Doe",
|
Name: "John Doe",
|
||||||
Email: utils.CompileUserEmail("johndoe", runtime.CookieDomain),
|
Email: utils.CompileUserEmail("johndoe", controllerConfig.CookieDomain),
|
||||||
Provider: "local",
|
Provider: "local",
|
||||||
}
|
}
|
||||||
bytes, err := json.Marshal(expectedUserContextResponse)
|
bytes, err := json.Marshal(expectedUserContextResponse)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
return string(bytes)
|
return string(bytes)
|
||||||
}(),
|
}(),
|
||||||
},
|
},
|
||||||
@@ -109,12 +117,13 @@ func TestContextController(t *testing.T) {
|
|||||||
group := router.Group("/api")
|
group := router.Group("/api")
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
controller.NewContextController(log, cfg, runtime, group)
|
contextController := controller.NewContextController(controllerConfig, group)
|
||||||
|
contextController.SetupRoutes()
|
||||||
|
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
request, err := http.NewRequest("GET", test.path, nil)
|
request, err := http.NewRequest("GET", test.path, nil)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
router.ServeHTTP(recorder, request)
|
router.ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
package controller
|
|
||||||
|
|
||||||
type UnauthorizedQuery struct {
|
|
||||||
Username string `url:"username"`
|
|
||||||
Resource string `url:"resource"`
|
|
||||||
GroupErr bool `url:"groupErr"`
|
|
||||||
IP string `url:"ip"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type RedirectQuery struct {
|
|
||||||
RedirectURI string `url:"redirect_uri"`
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
package controller_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
|
||||||
|
|
||||||
var testingTOTPSecret = "JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK"
|
|
||||||
|
|
||||||
func createTestConfigs(t *testing.T) (model.Config, model.RuntimeConfig) {
|
|
||||||
tempDir := t.TempDir()
|
|
||||||
|
|
||||||
config := model.Config{
|
|
||||||
UI: model.UIConfig{
|
|
||||||
Title: "Tinyauth Test",
|
|
||||||
ForgotPasswordMessage: "foo",
|
|
||||||
BackgroundImage: "/background.jpg",
|
|
||||||
WarningsEnabled: true,
|
|
||||||
},
|
|
||||||
OAuth: model.OAuthConfig{
|
|
||||||
AutoRedirect: "none",
|
|
||||||
},
|
|
||||||
OIDC: model.OIDCConfig{
|
|
||||||
Clients: map[string]model.OIDCClientConfig{
|
|
||||||
"test": {
|
|
||||||
ClientID: "some-client-id",
|
|
||||||
ClientSecret: "some-client-secret",
|
|
||||||
TrustedRedirectURIs: []string{"https://test.example.com/callback"},
|
|
||||||
Name: "Test Client",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
PrivateKeyPath: path.Join(tempDir, "key.pem"),
|
|
||||||
PublicKeyPath: path.Join(tempDir, "key.pub"),
|
|
||||||
},
|
|
||||||
Auth: model.AuthConfig{
|
|
||||||
SessionExpiry: 10,
|
|
||||||
LoginTimeout: 10,
|
|
||||||
LoginMaxRetries: 3,
|
|
||||||
},
|
|
||||||
Database: model.DatabaseConfig{
|
|
||||||
Path: path.Join(tempDir, "test.db"),
|
|
||||||
},
|
|
||||||
Resources: model.ResourcesConfig{
|
|
||||||
Enabled: true,
|
|
||||||
Path: path.Join(tempDir, "resources"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
passwd, err := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
runtime := model.RuntimeConfig{
|
|
||||||
ConfiguredProviders: []model.Provider{
|
|
||||||
{
|
|
||||||
Name: "Local",
|
|
||||||
ID: "local",
|
|
||||||
OAuth: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
LocalUsers: []model.LocalUser{
|
|
||||||
{
|
|
||||||
Username: "testuser",
|
|
||||||
Password: string(passwd),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Username: "totpuser",
|
|
||||||
Password: string(passwd),
|
|
||||||
TOTPSecret: testingTOTPSecret,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Username: "attruser",
|
|
||||||
Password: string(passwd),
|
|
||||||
Attributes: model.UserAttributes{
|
|
||||||
Name: "Alice Smith",
|
|
||||||
Email: "alice@example.com",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Username: "attrtotpuser",
|
|
||||||
Password: string(passwd),
|
|
||||||
TOTPSecret: testingTOTPSecret,
|
|
||||||
Attributes: model.UserAttributes{
|
|
||||||
Name: "Bob Jones",
|
|
||||||
Email: "bob@example.com",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
CookieDomain: "example.com",
|
|
||||||
AppURL: "https://tinyauth.example.com",
|
|
||||||
SessionCookieName: "tinyauth-session",
|
|
||||||
OIDCClients: func() []model.OIDCClientConfig {
|
|
||||||
var clients []model.OIDCClientConfig
|
|
||||||
for id, client := range config.OIDC.Clients {
|
|
||||||
client.ID = id
|
|
||||||
clients = append(clients, client)
|
|
||||||
}
|
|
||||||
return clients
|
|
||||||
}(),
|
|
||||||
}
|
|
||||||
|
|
||||||
return config, runtime
|
|
||||||
}
|
|
||||||
@@ -3,15 +3,18 @@ package controller
|
|||||||
import "github.com/gin-gonic/gin"
|
import "github.com/gin-gonic/gin"
|
||||||
|
|
||||||
type HealthController struct {
|
type HealthController struct {
|
||||||
|
router *gin.RouterGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHealthController(router *gin.RouterGroup) *HealthController {
|
func NewHealthController(router *gin.RouterGroup) *HealthController {
|
||||||
controller := &HealthController{}
|
return &HealthController{
|
||||||
|
router: router,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
router.GET("/healthz", controller.healthHandler)
|
func (controller *HealthController) SetupRoutes() {
|
||||||
router.HEAD("/healthz", controller.healthHandler)
|
controller.router.GET("/healthz", controller.healthHandler)
|
||||||
|
controller.router.HEAD("/healthz", controller.healthHandler)
|
||||||
return controller
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (controller *HealthController) healthHandler(c *gin.Context) {
|
func (controller *HealthController) healthHandler(c *gin.Context) {
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/controller"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/controller"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHealthController(t *testing.T) {
|
func TestHealthController(t *testing.T) {
|
||||||
|
tlog.NewTestLogger().Init()
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
description string
|
description string
|
||||||
path string
|
path string
|
||||||
@@ -29,7 +30,7 @@ func TestHealthController(t *testing.T) {
|
|||||||
"message": "Healthy",
|
"message": "Healthy",
|
||||||
}
|
}
|
||||||
bytes, err := json.Marshal(expectedHealthResponse)
|
bytes, err := json.Marshal(expectedHealthResponse)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
return string(bytes)
|
return string(bytes)
|
||||||
}(),
|
}(),
|
||||||
},
|
},
|
||||||
@@ -43,7 +44,7 @@ func TestHealthController(t *testing.T) {
|
|||||||
"message": "Healthy",
|
"message": "Healthy",
|
||||||
}
|
}
|
||||||
bytes, err := json.Marshal(expectedHealthResponse)
|
bytes, err := json.Marshal(expectedHealthResponse)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
return string(bytes)
|
return string(bytes)
|
||||||
}(),
|
}(),
|
||||||
},
|
},
|
||||||
@@ -55,12 +56,13 @@ func TestHealthController(t *testing.T) {
|
|||||||
group := router.Group("/api")
|
group := router.Group("/api")
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
controller.NewHealthController(group)
|
healthController := controller.NewHealthController(group)
|
||||||
|
healthController.SetupRoutes()
|
||||||
|
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
request, err := http.NewRequest(test.method, test.path, nil)
|
request, err := http.NewRequest(test.method, test.path, nil)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
router.ServeHTTP(recorder, request)
|
router.ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
"github.com/steveiliop56/tinyauth/internal/config"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
"github.com/steveiliop56/tinyauth/internal/repository"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
"github.com/steveiliop56/tinyauth/internal/service"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/go-querystring/query"
|
"github.com/google/go-querystring/query"
|
||||||
@@ -20,32 +20,33 @@ type OAuthRequest struct {
|
|||||||
Provider string `uri:"provider" binding:"required"`
|
Provider string `uri:"provider" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OAuthController struct {
|
type OAuthControllerConfig struct {
|
||||||
log *logger.Logger
|
CSRFCookieName string
|
||||||
config model.Config
|
OAuthSessionCookieName string
|
||||||
runtime model.RuntimeConfig
|
RedirectCookieName string
|
||||||
auth *service.AuthService
|
SecureCookie bool
|
||||||
|
AppURL string
|
||||||
|
CookieDomain string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOAuthController(
|
type OAuthController struct {
|
||||||
log *logger.Logger,
|
config OAuthControllerConfig
|
||||||
config model.Config,
|
router *gin.RouterGroup
|
||||||
runtimeConfig model.RuntimeConfig,
|
auth *service.AuthService
|
||||||
router *gin.RouterGroup,
|
}
|
||||||
auth *service.AuthService,
|
|
||||||
) *OAuthController {
|
|
||||||
controller := &OAuthController{
|
|
||||||
log: log,
|
|
||||||
config: config,
|
|
||||||
runtime: runtimeConfig,
|
|
||||||
auth: auth,
|
|
||||||
}
|
|
||||||
|
|
||||||
oauthGroup := router.Group("/oauth")
|
func NewOAuthController(config OAuthControllerConfig, router *gin.RouterGroup, auth *service.AuthService) *OAuthController {
|
||||||
|
return &OAuthController{
|
||||||
|
config: config,
|
||||||
|
router: router,
|
||||||
|
auth: auth,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (controller *OAuthController) SetupRoutes() {
|
||||||
|
oauthGroup := controller.router.Group("/oauth")
|
||||||
oauthGroup.GET("/url/:provider", controller.oauthURLHandler)
|
oauthGroup.GET("/url/:provider", controller.oauthURLHandler)
|
||||||
oauthGroup.GET("/callback/:provider", controller.oauthCallbackHandler)
|
oauthGroup.GET("/callback/:provider", controller.oauthCallbackHandler)
|
||||||
|
|
||||||
return controller
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
|
func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
|
||||||
@@ -53,7 +54,7 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
|
|||||||
|
|
||||||
err := c.BindUri(&req)
|
err := c.BindUri(&req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to bind URI")
|
tlog.App.Error().Err(err).Msg("Failed to bind URI")
|
||||||
c.JSON(400, gin.H{
|
c.JSON(400, gin.H{
|
||||||
"status": 400,
|
"status": 400,
|
||||||
"message": "Bad Request",
|
"message": "Bad Request",
|
||||||
@@ -66,7 +67,7 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
|
|||||||
err = c.BindQuery(&reqParams)
|
err = c.BindQuery(&reqParams)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to bind query parameters")
|
tlog.App.Error().Err(err).Msg("Failed to bind query parameters")
|
||||||
c.JSON(400, gin.H{
|
c.JSON(400, gin.H{
|
||||||
"status": 400,
|
"status": 400,
|
||||||
"message": "Bad Request",
|
"message": "Bad Request",
|
||||||
@@ -75,10 +76,10 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !controller.isOidcRequest(reqParams) {
|
if !controller.isOidcRequest(reqParams) {
|
||||||
isRedirectSafe := utils.IsRedirectSafe(reqParams.RedirectURI, controller.runtime.CookieDomain)
|
isRedirectSafe := utils.IsRedirectSafe(reqParams.RedirectURI, controller.config.CookieDomain)
|
||||||
|
|
||||||
if !isRedirectSafe {
|
if !isRedirectSafe {
|
||||||
controller.log.App.Warn().Str("redirectUri", reqParams.RedirectURI).Msg("Unsafe redirect URI, ignoring")
|
tlog.App.Warn().Str("redirect_uri", reqParams.RedirectURI).Msg("Unsafe redirect URI detected, ignoring")
|
||||||
reqParams.RedirectURI = ""
|
reqParams.RedirectURI = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,7 +87,7 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
|
|||||||
sessionId, _, err := controller.auth.NewOAuthSession(req.Provider, reqParams)
|
sessionId, _, err := controller.auth.NewOAuthSession(req.Provider, reqParams)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to create new OAuth session")
|
tlog.App.Error().Err(err).Msg("Failed to create OAuth session")
|
||||||
c.JSON(500, gin.H{
|
c.JSON(500, gin.H{
|
||||||
"status": 500,
|
"status": 500,
|
||||||
"message": "Internal Server Error",
|
"message": "Internal Server Error",
|
||||||
@@ -97,7 +98,7 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
|
|||||||
authUrl, err := controller.auth.GetOAuthURL(sessionId)
|
authUrl, err := controller.auth.GetOAuthURL(sessionId)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to get OAuth URL for session")
|
tlog.App.Error().Err(err).Msg("Failed to get OAuth URL")
|
||||||
c.JSON(500, gin.H{
|
c.JSON(500, gin.H{
|
||||||
"status": 500,
|
"status": 500,
|
||||||
"message": "Internal Server Error",
|
"message": "Internal Server Error",
|
||||||
@@ -105,7 +106,7 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.SetCookie(controller.runtime.OAuthSessionCookieName, sessionId, int(time.Hour.Seconds()), "/", controller.getCookieDomain(), controller.config.Auth.SecureCookie, true)
|
c.SetCookie(controller.config.OAuthSessionCookieName, sessionId, int(time.Hour.Seconds()), "/", fmt.Sprintf(".%s", controller.config.CookieDomain), controller.config.SecureCookie, true)
|
||||||
|
|
||||||
c.JSON(200, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"status": 200,
|
"status": 200,
|
||||||
@@ -119,7 +120,7 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
|||||||
|
|
||||||
err := c.BindUri(&req)
|
err := c.BindUri(&req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to bind URI")
|
tlog.App.Error().Err(err).Msg("Failed to bind URI")
|
||||||
c.JSON(400, gin.H{
|
c.JSON(400, gin.H{
|
||||||
"status": 400,
|
"status": 400,
|
||||||
"message": "Bad Request",
|
"message": "Bad Request",
|
||||||
@@ -127,20 +128,20 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionIdCookie, err := c.Cookie(controller.runtime.OAuthSessionCookieName)
|
sessionIdCookie, err := c.Cookie(controller.config.OAuthSessionCookieName)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to get OAuth session cookie")
|
tlog.App.Warn().Err(err).Msg("OAuth session cookie missing")
|
||||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.SetCookie(controller.runtime.OAuthSessionCookieName, "", -1, "/", controller.getCookieDomain(), controller.config.Auth.SecureCookie, true)
|
c.SetCookie(controller.config.OAuthSessionCookieName, "", -1, "/", fmt.Sprintf(".%s", controller.config.CookieDomain), controller.config.SecureCookie, true)
|
||||||
|
|
||||||
oauthPendingSession, err := controller.auth.GetOAuthPendingSession(sessionIdCookie)
|
oauthPendingSession, err := controller.auth.GetOAuthPendingSession(sessionIdCookie)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to get pending OAuth session")
|
tlog.App.Error().Err(err).Msg("Failed to get OAuth pending session")
|
||||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -149,7 +150,7 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
|||||||
|
|
||||||
state := c.Query("state")
|
state := c.Query("state")
|
||||||
if state != oauthPendingSession.State {
|
if state != oauthPendingSession.State {
|
||||||
controller.log.App.Warn().Msg("OAuth state mismatch")
|
tlog.App.Warn().Err(err).Msg("CSRF token mismatch")
|
||||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -158,7 +159,7 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
|||||||
_, err = controller.auth.GetOAuthToken(sessionIdCookie, code)
|
_, err = controller.auth.GetOAuthToken(sessionIdCookie, code)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to exchange code for token")
|
tlog.App.Error().Err(err).Msg("Failed to exchange code for token")
|
||||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -166,21 +167,21 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
|||||||
user, err := controller.auth.GetOAuthUserinfo(sessionIdCookie)
|
user, err := controller.auth.GetOAuthUserinfo(sessionIdCookie)
|
||||||
|
|
||||||
if user.Email == "" {
|
if user.Email == "" {
|
||||||
controller.log.App.Warn().Msg("OAuth provider did not return an email")
|
tlog.App.Error().Msg("OAuth provider did not return an email")
|
||||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !controller.auth.IsEmailWhitelisted(user.Email) {
|
if !controller.auth.IsEmailWhitelisted(user.Email) {
|
||||||
controller.log.App.Warn().Str("email", user.Email).Msg("Email not whitelisted, denying access")
|
tlog.App.Warn().Str("email", user.Email).Msg("Email not whitelisted")
|
||||||
controller.log.AuditLoginFailure(user.Email, req.Provider, c.ClientIP(), "email not whitelisted")
|
tlog.AuditLoginFailure(c, user.Email, req.Provider, "email not whitelisted")
|
||||||
|
|
||||||
queries, err := query.Values(UnauthorizedQuery{
|
queries, err := query.Values(config.UnauthorizedQuery{
|
||||||
Username: user.Email,
|
Username: user.Email,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to encode unauthorized query")
|
tlog.App.Error().Err(err).Msg("Failed to encode unauthorized query")
|
||||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -192,33 +193,33 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
|||||||
var name string
|
var name string
|
||||||
|
|
||||||
if strings.TrimSpace(user.Name) != "" {
|
if strings.TrimSpace(user.Name) != "" {
|
||||||
controller.log.App.Debug().Msg("Using name from OAuth provider")
|
tlog.App.Debug().Msg("Using name from OAuth provider")
|
||||||
name = user.Name
|
name = user.Name
|
||||||
} else {
|
} else {
|
||||||
controller.log.App.Debug().Msg("No name from OAuth provider, generating from email")
|
tlog.App.Debug().Msg("No name from OAuth provider, using pseudo name")
|
||||||
name = fmt.Sprintf("%s (%s)", utils.Capitalize(strings.Split(user.Email, "@")[0]), strings.Split(user.Email, "@")[1])
|
name = fmt.Sprintf("%s (%s)", utils.Capitalize(strings.Split(user.Email, "@")[0]), strings.Split(user.Email, "@")[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
var username string
|
var username string
|
||||||
|
|
||||||
if strings.TrimSpace(user.PreferredUsername) != "" {
|
if strings.TrimSpace(user.PreferredUsername) != "" {
|
||||||
controller.log.App.Debug().Msg("Using preferred username from OAuth provider")
|
tlog.App.Debug().Msg("Using preferred username from OAuth provider")
|
||||||
username = user.PreferredUsername
|
username = user.PreferredUsername
|
||||||
} else {
|
} else {
|
||||||
controller.log.App.Debug().Msg("No preferred username from OAuth provider, generating from email")
|
tlog.App.Debug().Msg("No preferred username from OAuth provider, using pseudo username")
|
||||||
username = strings.Replace(user.Email, "@", "_", 1)
|
username = strings.Replace(user.Email, "@", "_", 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
svc, err := controller.auth.GetOAuthService(sessionIdCookie)
|
svc, err := controller.auth.GetOAuthService(sessionIdCookie)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to get OAuth service for session")
|
tlog.App.Error().Err(err).Msg("Failed to get OAuth service for session")
|
||||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if svc.ID() != req.Provider {
|
if svc.ID() != req.Provider {
|
||||||
controller.log.App.Warn().Msgf("OAuth provider mismatch: expected %s, got %s", req.Provider, svc.ID())
|
tlog.App.Error().Msgf("OAuth service ID mismatch: expected %s, got %s", svc.ID(), req.Provider)
|
||||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -233,25 +234,23 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
|||||||
OAuthSub: user.Sub,
|
OAuthSub: user.Sub,
|
||||||
}
|
}
|
||||||
|
|
||||||
controller.log.App.Debug().Msg("Creating session cookie for user")
|
tlog.App.Trace().Interface("session_cookie", sessionCookie).Msg("Creating session cookie")
|
||||||
|
|
||||||
cookie, err := controller.auth.CreateSession(c, sessionCookie)
|
err = controller.auth.CreateSessionCookie(c, &sessionCookie)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to create session cookie")
|
tlog.App.Error().Err(err).Msg("Failed to create session cookie")
|
||||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
http.SetCookie(c.Writer, cookie)
|
tlog.AuditLoginSuccess(c, sessionCookie.Username, sessionCookie.Provider)
|
||||||
|
|
||||||
controller.log.AuditLoginSuccess(sessionCookie.Username, sessionCookie.Provider, c.ClientIP())
|
|
||||||
|
|
||||||
if controller.isOidcRequest(oauthPendingSession.CallbackParams) {
|
if controller.isOidcRequest(oauthPendingSession.CallbackParams) {
|
||||||
controller.log.App.Debug().Msg("OIDC request detected, redirecting to authorization endpoint with callback params")
|
tlog.App.Debug().Msg("OIDC request, redirecting to authorize page")
|
||||||
queries, err := query.Values(oauthPendingSession.CallbackParams)
|
queries, err := query.Values(oauthPendingSession.CallbackParams)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to encode OIDC callback query")
|
tlog.App.Error().Err(err).Msg("Failed to encode OIDC callback query")
|
||||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -260,12 +259,12 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if oauthPendingSession.CallbackParams.RedirectURI != "" {
|
if oauthPendingSession.CallbackParams.RedirectURI != "" {
|
||||||
queries, err := query.Values(RedirectQuery{
|
queries, err := query.Values(config.RedirectQuery{
|
||||||
RedirectURI: oauthPendingSession.CallbackParams.RedirectURI,
|
RedirectURI: oauthPendingSession.CallbackParams.RedirectURI,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to encode redirect query")
|
tlog.App.Error().Err(err).Msg("Failed to encode redirect URI query")
|
||||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -283,10 +282,3 @@ func (controller *OAuthController) isOidcRequest(params service.OAuthURLParams)
|
|||||||
params.ClientID != "" &&
|
params.ClientID != "" &&
|
||||||
params.RedirectURI != ""
|
params.RedirectURI != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (controller *OAuthController) getCookieDomain() string {
|
|
||||||
if controller.config.Auth.SubdomainsEnabled {
|
|
||||||
return "." + controller.runtime.CookieDomain
|
|
||||||
}
|
|
||||||
return controller.runtime.CookieDomain
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,15 +10,17 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/go-querystring/query"
|
"github.com/google/go-querystring/query"
|
||||||
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
"github.com/steveiliop56/tinyauth/internal/service"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type OIDCControllerConfig struct{}
|
||||||
|
|
||||||
type OIDCController struct {
|
type OIDCController struct {
|
||||||
log *logger.Logger
|
config OIDCControllerConfig
|
||||||
oidc *service.OIDCService
|
router *gin.RouterGroup
|
||||||
|
oidc *service.OIDCService
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthorizeCallback struct {
|
type AuthorizeCallback struct {
|
||||||
@@ -55,23 +57,21 @@ type ClientCredentials struct {
|
|||||||
ClientSecret string
|
ClientSecret string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOIDCController(
|
func NewOIDCController(config OIDCControllerConfig, oidcService *service.OIDCService, router *gin.RouterGroup) *OIDCController {
|
||||||
log *logger.Logger,
|
return &OIDCController{
|
||||||
oidcService *service.OIDCService,
|
config: config,
|
||||||
router *gin.RouterGroup) *OIDCController {
|
oidc: oidcService,
|
||||||
controller := &OIDCController{
|
router: router,
|
||||||
log: log,
|
|
||||||
oidc: oidcService,
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
oidcGroup := router.Group("/oidc")
|
func (controller *OIDCController) SetupRoutes() {
|
||||||
|
oidcGroup := controller.router.Group("/oidc")
|
||||||
oidcGroup.GET("/clients/:id", controller.GetClientInfo)
|
oidcGroup.GET("/clients/:id", controller.GetClientInfo)
|
||||||
oidcGroup.POST("/authorize", controller.Authorize)
|
oidcGroup.POST("/authorize", controller.Authorize)
|
||||||
oidcGroup.POST("/token", controller.Token)
|
oidcGroup.POST("/token", controller.Token)
|
||||||
oidcGroup.GET("/userinfo", controller.Userinfo)
|
oidcGroup.GET("/userinfo", controller.Userinfo)
|
||||||
oidcGroup.POST("/userinfo", controller.Userinfo)
|
oidcGroup.POST("/userinfo", controller.Userinfo)
|
||||||
|
|
||||||
return controller
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (controller *OIDCController) GetClientInfo(c *gin.Context) {
|
func (controller *OIDCController) GetClientInfo(c *gin.Context) {
|
||||||
@@ -79,7 +79,7 @@ func (controller *OIDCController) GetClientInfo(c *gin.Context) {
|
|||||||
|
|
||||||
err := c.BindUri(&req)
|
err := c.BindUri(&req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to bind URI")
|
tlog.App.Error().Err(err).Msg("Failed to bind URI")
|
||||||
c.JSON(400, gin.H{
|
c.JSON(400, gin.H{
|
||||||
"status": 400,
|
"status": 400,
|
||||||
"message": "Bad Request",
|
"message": "Bad Request",
|
||||||
@@ -90,7 +90,7 @@ func (controller *OIDCController) GetClientInfo(c *gin.Context) {
|
|||||||
client, ok := controller.oidc.GetClient(req.ClientID)
|
client, ok := controller.oidc.GetClient(req.ClientID)
|
||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
controller.log.App.Warn().Str("clientId", req.ClientID).Msg("Client not found")
|
tlog.App.Warn().Str("client_id", req.ClientID).Msg("Client not found")
|
||||||
c.JSON(404, gin.H{
|
c.JSON(404, gin.H{
|
||||||
"status": 404,
|
"status": 404,
|
||||||
"message": "Client not found",
|
"message": "Client not found",
|
||||||
@@ -106,19 +106,19 @@ func (controller *OIDCController) GetClientInfo(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (controller *OIDCController) Authorize(c *gin.Context) {
|
func (controller *OIDCController) Authorize(c *gin.Context) {
|
||||||
if controller.oidc == nil {
|
if !controller.oidc.IsConfigured() {
|
||||||
controller.authorizeError(c, errors.New("err_oidc_not_configured"), "OIDC not configured", "This instance is not configured for OIDC", "", "", "")
|
controller.authorizeError(c, errors.New("err_oidc_not_configured"), "OIDC not configured", "This instance is not configured for OIDC", "", "", "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userContext, err := new(model.UserContext).NewFromGin(c)
|
userContext, err := utils.GetContext(c)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.authorizeError(c, err, "Failed to get user context", "User is not logged in or the session is invalid", "", "", "")
|
controller.authorizeError(c, err, "Failed to get user context", "User is not logged in or the session is invalid", "", "", "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !userContext.Authenticated {
|
if !userContext.IsLoggedIn {
|
||||||
controller.authorizeError(c, errors.New("err user not logged in"), "User not logged in", "The user is not logged in", "", "", "")
|
controller.authorizeError(c, errors.New("err user not logged in"), "User not logged in", "The user is not logged in", "", "", "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -141,7 +141,7 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
|
|||||||
err = controller.oidc.ValidateAuthorizeParams(req)
|
err = controller.oidc.ValidateAuthorizeParams(req)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Warn().Err(err).Msg("Failed to validate authorize params")
|
tlog.App.Error().Err(err).Msg("Failed to validate authorize params")
|
||||||
if err.Error() != "invalid_request_uri" {
|
if err.Error() != "invalid_request_uri" {
|
||||||
controller.authorizeError(c, err, "Failed validate authorize params", "Invalid request parameters", req.RedirectURI, err.Error(), req.State)
|
controller.authorizeError(c, err, "Failed validate authorize params", "Invalid request parameters", req.RedirectURI, err.Error(), req.State)
|
||||||
return
|
return
|
||||||
@@ -151,7 +151,7 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WARNING: Since Tinyauth is stateless, we cannot have a sub that never changes. We will just create a uuid out of the username and client name which remains stable, but if username or client name changes then sub changes too.
|
// WARNING: Since Tinyauth is stateless, we cannot have a sub that never changes. We will just create a uuid out of the username and client name which remains stable, but if username or client name changes then sub changes too.
|
||||||
sub := utils.GenerateUUID(fmt.Sprintf("%s:%s", userContext.GetUsername(), client.ID))
|
sub := utils.GenerateUUID(fmt.Sprintf("%s:%s", userContext.Username, client.ID))
|
||||||
code := utils.GenerateString(32)
|
code := utils.GenerateString(32)
|
||||||
|
|
||||||
// Before storing the code, delete old session
|
// Before storing the code, delete old session
|
||||||
@@ -170,10 +170,10 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
|
|||||||
|
|
||||||
// We also need a snapshot of the user that authorized this (skip if no openid scope)
|
// We also need a snapshot of the user that authorized this (skip if no openid scope)
|
||||||
if slices.Contains(strings.Fields(req.Scope), "openid") {
|
if slices.Contains(strings.Fields(req.Scope), "openid") {
|
||||||
err = controller.oidc.StoreUserinfo(c, sub, *userContext, req)
|
err = controller.oidc.StoreUserinfo(c, sub, userContext, req)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to store user info")
|
tlog.App.Error().Err(err).Msg("Failed to insert user info into database")
|
||||||
controller.authorizeError(c, err, "Failed to store user info", "Failed to store user info", req.RedirectURI, "server_error", req.State)
|
controller.authorizeError(c, err, "Failed to store user info", "Failed to store user info", req.RedirectURI, "server_error", req.State)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -196,8 +196,8 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (controller *OIDCController) Token(c *gin.Context) {
|
func (controller *OIDCController) Token(c *gin.Context) {
|
||||||
if controller.oidc == nil {
|
if !controller.oidc.IsConfigured() {
|
||||||
controller.log.App.Warn().Msg("Received OIDC request but OIDC server is not configured")
|
tlog.App.Warn().Msg("OIDC not configured")
|
||||||
c.JSON(404, gin.H{
|
c.JSON(404, gin.H{
|
||||||
"error": "not_found",
|
"error": "not_found",
|
||||||
})
|
})
|
||||||
@@ -208,7 +208,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
|||||||
|
|
||||||
err := c.Bind(&req)
|
err := c.Bind(&req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Warn().Err(err).Msg("Failed to bind token request")
|
tlog.App.Error().Err(err).Msg("Failed to bind token request")
|
||||||
c.JSON(400, gin.H{
|
c.JSON(400, gin.H{
|
||||||
"error": "invalid_request",
|
"error": "invalid_request",
|
||||||
})
|
})
|
||||||
@@ -217,7 +217,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
|||||||
|
|
||||||
err = controller.oidc.ValidateGrantType(req.GrantType)
|
err = controller.oidc.ValidateGrantType(req.GrantType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Warn().Err(err).Msg("Invalid grant type")
|
tlog.App.Warn().Str("grant_type", req.GrantType).Msg("Unsupported grant type")
|
||||||
c.JSON(400, gin.H{
|
c.JSON(400, gin.H{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
@@ -232,12 +232,12 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
|||||||
|
|
||||||
// If it fails, we try basic auth
|
// If it fails, we try basic auth
|
||||||
if creds.ClientID == "" || creds.ClientSecret == "" {
|
if creds.ClientID == "" || creds.ClientSecret == "" {
|
||||||
controller.log.App.Debug().Msg("Client credentials not found in form, trying basic auth")
|
tlog.App.Debug().Msg("Tried form values and they are empty, trying basic auth")
|
||||||
|
|
||||||
clientId, clientSecret, ok := c.Request.BasicAuth()
|
clientId, clientSecret, ok := c.Request.BasicAuth()
|
||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
controller.log.App.Warn().Msg("Client credentials not found in basic auth")
|
tlog.App.Error().Msg("Missing authorization header")
|
||||||
c.Header("www-authenticate", `Basic realm="Tinyauth OIDC Token Endpoint"`)
|
c.Header("www-authenticate", `Basic realm="Tinyauth OIDC Token Endpoint"`)
|
||||||
c.JSON(400, gin.H{
|
c.JSON(400, gin.H{
|
||||||
"error": "invalid_client",
|
"error": "invalid_client",
|
||||||
@@ -254,7 +254,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
|||||||
client, ok := controller.oidc.GetClient(creds.ClientID)
|
client, ok := controller.oidc.GetClient(creds.ClientID)
|
||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
controller.log.App.Warn().Str("clientId", creds.ClientID).Msg("Client not found")
|
tlog.App.Warn().Str("client_id", creds.ClientID).Msg("Client not found")
|
||||||
c.JSON(400, gin.H{
|
c.JSON(400, gin.H{
|
||||||
"error": "invalid_client",
|
"error": "invalid_client",
|
||||||
})
|
})
|
||||||
@@ -262,7 +262,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if client.ClientSecret != creds.ClientSecret {
|
if client.ClientSecret != creds.ClientSecret {
|
||||||
controller.log.App.Warn().Str("clientId", creds.ClientID).Msg("Invalid client secret")
|
tlog.App.Warn().Str("client_id", creds.ClientID).Msg("Invalid client secret")
|
||||||
c.JSON(400, gin.H{
|
c.JSON(400, gin.H{
|
||||||
"error": "invalid_client",
|
"error": "invalid_client",
|
||||||
})
|
})
|
||||||
@@ -276,30 +276,30 @@ 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 delete code")
|
tlog.App.Error().Err(err).Msg("Failed to delete access token by code hash")
|
||||||
}
|
}
|
||||||
if errors.Is(err, service.ErrCodeNotFound) {
|
if errors.Is(err, service.ErrCodeNotFound) {
|
||||||
controller.log.App.Warn().Msg("Code not found")
|
tlog.App.Warn().Msg("Code not found")
|
||||||
c.JSON(400, gin.H{
|
c.JSON(400, gin.H{
|
||||||
"error": "invalid_grant",
|
"error": "invalid_grant",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if errors.Is(err, service.ErrCodeExpired) {
|
if errors.Is(err, service.ErrCodeExpired) {
|
||||||
controller.log.App.Warn().Msg("Code expired")
|
tlog.App.Warn().Msg("Code expired")
|
||||||
c.JSON(400, gin.H{
|
c.JSON(400, gin.H{
|
||||||
"error": "invalid_grant",
|
"error": "invalid_grant",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if errors.Is(err, service.ErrInvalidClient) {
|
if errors.Is(err, service.ErrInvalidClient) {
|
||||||
controller.log.App.Warn().Msg("Code does not belong to client")
|
tlog.App.Warn().Msg("Invalid client ID")
|
||||||
c.JSON(400, gin.H{
|
c.JSON(400, gin.H{
|
||||||
"error": "invalid_client",
|
"error": "invalid_client",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to get code entry")
|
tlog.App.Warn().Err(err).Msg("Failed to get OIDC code entry")
|
||||||
c.JSON(400, gin.H{
|
c.JSON(400, gin.H{
|
||||||
"error": "server_error",
|
"error": "server_error",
|
||||||
})
|
})
|
||||||
@@ -307,7 +307,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if entry.RedirectURI != req.RedirectURI {
|
if entry.RedirectURI != req.RedirectURI {
|
||||||
controller.log.App.Warn().Msg("Redirect URI does not match")
|
tlog.App.Warn().Str("redirect_uri", req.RedirectURI).Msg("Redirect URI mismatch")
|
||||||
c.JSON(400, gin.H{
|
c.JSON(400, gin.H{
|
||||||
"error": "invalid_grant",
|
"error": "invalid_grant",
|
||||||
})
|
})
|
||||||
@@ -317,7 +317,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
|||||||
ok := controller.oidc.ValidatePKCE(entry.CodeChallenge, req.CodeVerifier)
|
ok := controller.oidc.ValidatePKCE(entry.CodeChallenge, req.CodeVerifier)
|
||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
controller.log.App.Warn().Msg("PKCE validation failed")
|
tlog.App.Warn().Msg("PKCE validation failed")
|
||||||
c.JSON(400, gin.H{
|
c.JSON(400, gin.H{
|
||||||
"error": "invalid_grant",
|
"error": "invalid_grant",
|
||||||
})
|
})
|
||||||
@@ -327,7 +327,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
|||||||
tokenRes, err := controller.oidc.GenerateAccessToken(c, client, entry)
|
tokenRes, err := controller.oidc.GenerateAccessToken(c, client, entry)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to generate access token")
|
tlog.App.Error().Err(err).Msg("Failed to generate access token")
|
||||||
c.JSON(400, gin.H{
|
c.JSON(400, gin.H{
|
||||||
"error": "server_error",
|
"error": "server_error",
|
||||||
})
|
})
|
||||||
@@ -340,7 +340,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, service.ErrTokenExpired) {
|
if errors.Is(err, service.ErrTokenExpired) {
|
||||||
controller.log.App.Warn().Msg("Refresh token expired")
|
tlog.App.Error().Err(err).Msg("Refresh token expired")
|
||||||
c.JSON(400, gin.H{
|
c.JSON(400, gin.H{
|
||||||
"error": "invalid_grant",
|
"error": "invalid_grant",
|
||||||
})
|
})
|
||||||
@@ -348,14 +348,14 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, service.ErrInvalidClient) {
|
if errors.Is(err, service.ErrInvalidClient) {
|
||||||
controller.log.App.Warn().Msg("Refresh token does not belong to client")
|
tlog.App.Error().Err(err).Msg("Invalid client")
|
||||||
c.JSON(400, gin.H{
|
c.JSON(400, gin.H{
|
||||||
"error": "invalid_grant",
|
"error": "invalid_grant",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to refresh access token")
|
tlog.App.Error().Err(err).Msg("Failed to refresh access token")
|
||||||
c.JSON(400, gin.H{
|
c.JSON(400, gin.H{
|
||||||
"error": "server_error",
|
"error": "server_error",
|
||||||
})
|
})
|
||||||
@@ -372,8 +372,8 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (controller *OIDCController) Userinfo(c *gin.Context) {
|
func (controller *OIDCController) Userinfo(c *gin.Context) {
|
||||||
if controller.oidc == nil {
|
if !controller.oidc.IsConfigured() {
|
||||||
controller.log.App.Warn().Msg("Received OIDC userinfo request but OIDC server is not configured")
|
tlog.App.Warn().Msg("OIDC not configured")
|
||||||
c.JSON(404, gin.H{
|
c.JSON(404, gin.H{
|
||||||
"error": "not_found",
|
"error": "not_found",
|
||||||
})
|
})
|
||||||
@@ -386,7 +386,7 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
|
|||||||
if authorization != "" {
|
if authorization != "" {
|
||||||
tokenType, bearerToken, ok := strings.Cut(authorization, " ")
|
tokenType, bearerToken, ok := strings.Cut(authorization, " ")
|
||||||
if !ok {
|
if !ok {
|
||||||
controller.log.App.Warn().Msg("OIDC userinfo accessed with invalid authorization header")
|
tlog.App.Warn().Msg("OIDC userinfo accessed with malformed authorization header")
|
||||||
c.JSON(401, gin.H{
|
c.JSON(401, gin.H{
|
||||||
"error": "invalid_request",
|
"error": "invalid_request",
|
||||||
})
|
})
|
||||||
@@ -394,7 +394,7 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if strings.ToLower(tokenType) != "bearer" {
|
if strings.ToLower(tokenType) != "bearer" {
|
||||||
controller.log.App.Warn().Msg("OIDC userinfo accessed with non-bearer token")
|
tlog.App.Warn().Msg("OIDC userinfo accessed with invalid token type")
|
||||||
c.JSON(401, gin.H{
|
c.JSON(401, gin.H{
|
||||||
"error": "invalid_request",
|
"error": "invalid_request",
|
||||||
})
|
})
|
||||||
@@ -404,7 +404,7 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
|
|||||||
token = bearerToken
|
token = bearerToken
|
||||||
} else if c.Request.Method == http.MethodPost {
|
} else if c.Request.Method == http.MethodPost {
|
||||||
if c.ContentType() != "application/x-www-form-urlencoded" {
|
if c.ContentType() != "application/x-www-form-urlencoded" {
|
||||||
controller.log.App.Warn().Msg("OIDC userinfo POST accessed with invalid content type")
|
tlog.App.Warn().Msg("OIDC userinfo POST accessed with invalid content type")
|
||||||
c.JSON(400, gin.H{
|
c.JSON(400, gin.H{
|
||||||
"error": "invalid_request",
|
"error": "invalid_request",
|
||||||
})
|
})
|
||||||
@@ -412,14 +412,14 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
token = c.PostForm("access_token")
|
token = c.PostForm("access_token")
|
||||||
if token == "" {
|
if token == "" {
|
||||||
controller.log.App.Warn().Msg("OIDC userinfo POST accessed without access_token")
|
tlog.App.Warn().Msg("OIDC userinfo POST accessed without access_token in body")
|
||||||
c.JSON(401, gin.H{
|
c.JSON(401, gin.H{
|
||||||
"error": "invalid_request",
|
"error": "invalid_request",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
controller.log.App.Warn().Msg("OIDC userinfo accessed without authorization header or POST body")
|
tlog.App.Warn().Msg("OIDC userinfo accessed without authorization header")
|
||||||
c.JSON(401, gin.H{
|
c.JSON(401, gin.H{
|
||||||
"error": "invalid_request",
|
"error": "invalid_request",
|
||||||
})
|
})
|
||||||
@@ -429,15 +429,15 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
|
|||||||
entry, err := controller.oidc.GetAccessToken(c, controller.oidc.Hash(token))
|
entry, err := controller.oidc.GetAccessToken(c, controller.oidc.Hash(token))
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, service.ErrTokenNotFound) {
|
if err == service.ErrTokenNotFound {
|
||||||
controller.log.App.Warn().Msg("OIDC userinfo accessed with invalid token")
|
tlog.App.Warn().Msg("OIDC userinfo accessed with invalid token")
|
||||||
c.JSON(401, gin.H{
|
c.JSON(401, gin.H{
|
||||||
"error": "invalid_grant",
|
"error": "invalid_grant",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to get access token")
|
tlog.App.Err(err).Msg("Failed to get token entry")
|
||||||
c.JSON(401, gin.H{
|
c.JSON(401, gin.H{
|
||||||
"error": "server_error",
|
"error": "server_error",
|
||||||
})
|
})
|
||||||
@@ -446,7 +446,7 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
|
|||||||
|
|
||||||
// If we don't have the openid scope, return an error
|
// If we don't have the openid scope, return an error
|
||||||
if !slices.Contains(strings.Split(entry.Scope, ","), "openid") {
|
if !slices.Contains(strings.Split(entry.Scope, ","), "openid") {
|
||||||
controller.log.App.Warn().Msg("OIDC userinfo accessed with token missing openid scope")
|
tlog.App.Warn().Msg("OIDC userinfo accessed without openid scope")
|
||||||
c.JSON(401, gin.H{
|
c.JSON(401, gin.H{
|
||||||
"error": "invalid_scope",
|
"error": "invalid_scope",
|
||||||
})
|
})
|
||||||
@@ -456,7 +456,7 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
|
|||||||
user, err := controller.oidc.GetUserinfo(c, entry.Sub)
|
user, err := controller.oidc.GetUserinfo(c, entry.Sub)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to get user info")
|
tlog.App.Err(err).Msg("Failed to get user entry")
|
||||||
c.JSON(401, gin.H{
|
c.JSON(401, gin.H{
|
||||||
"error": "server_error",
|
"error": "server_error",
|
||||||
})
|
})
|
||||||
@@ -467,7 +467,7 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (controller *OIDCController) authorizeError(c *gin.Context, err error, reason string, reasonUser string, callback string, callbackError string, state string) {
|
func (controller *OIDCController) authorizeError(c *gin.Context, err error, reason string, reasonUser string, callback string, callbackError string, state string) {
|
||||||
controller.log.App.Warn().Err(err).Str("reason", reason).Msg("Authorization error")
|
tlog.App.Error().Err(err).Msg(reason)
|
||||||
|
|
||||||
if callback != "" {
|
if callback != "" {
|
||||||
errorQueries := CallbackError{
|
errorQueries := CallbackError{
|
||||||
|
|||||||
@@ -1,45 +1,55 @@
|
|||||||
package controller_test
|
package controller_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/go-querystring/query"
|
"github.com/google/go-querystring/query"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/bootstrap"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/config"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/controller"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/repository"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/service"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/bootstrap"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/controller"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestOIDCController(t *testing.T) {
|
func TestOIDCController(t *testing.T) {
|
||||||
log := logger.NewLogger().WithTestConfig()
|
tlog.NewTestLogger().Init()
|
||||||
log.Init()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
cfg, runtime := createTestConfigs(t)
|
oidcServiceCfg := service.OIDCServiceConfig{
|
||||||
|
Clients: map[string]config.OIDCClientConfig{
|
||||||
|
"test": {
|
||||||
|
ClientID: "some-client-id",
|
||||||
|
ClientSecret: "some-client-secret",
|
||||||
|
TrustedRedirectURIs: []string{"https://test.example.com/callback"},
|
||||||
|
Name: "Test Client",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PrivateKeyPath: path.Join(tempDir, "key.pem"),
|
||||||
|
PublicKeyPath: path.Join(tempDir, "key.pub"),
|
||||||
|
Issuer: "https://tinyauth.example.com",
|
||||||
|
SessionExpiry: 500,
|
||||||
|
}
|
||||||
|
|
||||||
|
controllerCfg := controller.OIDCControllerConfig{}
|
||||||
|
|
||||||
simpleCtx := func(c *gin.Context) {
|
simpleCtx := func(c *gin.Context) {
|
||||||
c.Set("context", &model.UserContext{
|
c.Set("context", &config.UserContext{
|
||||||
Authenticated: true,
|
Username: "test",
|
||||||
Provider: model.ProviderLocal,
|
Name: "Test User",
|
||||||
Local: &model.LocalContext{
|
Email: "test@example.com",
|
||||||
BaseContext: model.BaseContext{
|
IsLoggedIn: true,
|
||||||
Username: "test",
|
Provider: "local",
|
||||||
Name: "Test User",
|
|
||||||
Email: "test@example.com",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
@@ -89,7 +99,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
|
|
||||||
var res map[string]any
|
var res map[string]any
|
||||||
err := json.Unmarshal(recorder.Body.Bytes(), &res)
|
err := json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, res["redirect_uri"], "https://tinyauth.example.com/error?error=User+is+not+logged+in+or+the+session+is+invalid")
|
assert.Equal(t, res["redirect_uri"], "https://tinyauth.example.com/error?error=User+is+not+logged+in+or+the+session+is+invalid")
|
||||||
},
|
},
|
||||||
@@ -109,7 +119,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
Nonce: "some-nonce",
|
Nonce: "some-nonce",
|
||||||
}
|
}
|
||||||
reqBodyBytes, err := json.Marshal(reqBody)
|
reqBodyBytes, err := json.Marshal(reqBody)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/oidc/authorize", strings.NewReader(string(reqBodyBytes)))
|
req := httptest.NewRequest("POST", "/api/oidc/authorize", strings.NewReader(string(reqBodyBytes)))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
@@ -117,7 +127,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
|
|
||||||
var res map[string]any
|
var res map[string]any
|
||||||
err = json.Unmarshal(recorder.Body.Bytes(), &res)
|
err = json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, res["redirect_uri"], "https://test.example.com/callback?error=unsupported_response_type&error_description=Invalid+request+parameters&state=some-state")
|
assert.Equal(t, res["redirect_uri"], "https://test.example.com/callback?error=unsupported_response_type&error_description=Invalid+request+parameters&state=some-state")
|
||||||
},
|
},
|
||||||
@@ -137,7 +147,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
Nonce: "some-nonce",
|
Nonce: "some-nonce",
|
||||||
}
|
}
|
||||||
reqBodyBytes, err := json.Marshal(reqBody)
|
reqBodyBytes, err := json.Marshal(reqBody)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/oidc/authorize", strings.NewReader(string(reqBodyBytes)))
|
req := httptest.NewRequest("POST", "/api/oidc/authorize", strings.NewReader(string(reqBodyBytes)))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
@@ -146,11 +156,11 @@ func TestOIDCController(t *testing.T) {
|
|||||||
|
|
||||||
var res map[string]any
|
var res map[string]any
|
||||||
err = json.Unmarshal(recorder.Body.Bytes(), &res)
|
err = json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
redirectURI := res["redirect_uri"].(string)
|
redirectURI := res["redirect_uri"].(string)
|
||||||
url, err := url.Parse(redirectURI)
|
url, err := url.Parse(redirectURI)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
queryParams := url.Query()
|
queryParams := url.Query()
|
||||||
assert.Equal(t, queryParams.Get("state"), "some-state")
|
assert.Equal(t, queryParams.Get("state"), "some-state")
|
||||||
@@ -169,7 +179,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
RedirectURI: "https://test.example.com/callback",
|
RedirectURI: "https://test.example.com/callback",
|
||||||
}
|
}
|
||||||
reqBodyEncoded, err := query.Values(reqBody)
|
reqBodyEncoded, err := query.Values(reqBody)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
|
req := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
@@ -177,7 +187,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
|
|
||||||
var res map[string]any
|
var res map[string]any
|
||||||
err = json.Unmarshal(recorder.Body.Bytes(), &res)
|
err = json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, res["error"], "unsupported_grant_type")
|
assert.Equal(t, res["error"], "unsupported_grant_type")
|
||||||
},
|
},
|
||||||
@@ -192,7 +202,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
RedirectURI: "https://test.example.com/callback",
|
RedirectURI: "https://test.example.com/callback",
|
||||||
}
|
}
|
||||||
reqBodyEncoded, err := query.Values(reqBody)
|
reqBodyEncoded, err := query.Values(reqBody)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
|
req := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
@@ -230,7 +240,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
RedirectURI: "https://test.example.com/callback",
|
RedirectURI: "https://test.example.com/callback",
|
||||||
}
|
}
|
||||||
reqBodyEncoded, err := query.Values(reqBody)
|
reqBodyEncoded, err := query.Values(reqBody)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
|
req := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
@@ -253,11 +263,11 @@ func TestOIDCController(t *testing.T) {
|
|||||||
|
|
||||||
var authorizeRes map[string]any
|
var authorizeRes map[string]any
|
||||||
err := json.Unmarshal(authorizeTestRecorder.Body.Bytes(), &authorizeRes)
|
err := json.Unmarshal(authorizeTestRecorder.Body.Bytes(), &authorizeRes)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
redirectURI := authorizeRes["redirect_uri"].(string)
|
redirectURI := authorizeRes["redirect_uri"].(string)
|
||||||
url, err := url.Parse(redirectURI)
|
url, err := url.Parse(redirectURI)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
queryParams := url.Query()
|
queryParams := url.Query()
|
||||||
code := queryParams.Get("code")
|
code := queryParams.Get("code")
|
||||||
@@ -269,7 +279,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
RedirectURI: "https://test.example.com/callback",
|
RedirectURI: "https://test.example.com/callback",
|
||||||
}
|
}
|
||||||
reqBodyEncoded, err := query.Values(reqBody)
|
reqBodyEncoded, err := query.Values(reqBody)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
|
req := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
@@ -292,7 +302,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
|
|
||||||
var tokenRes map[string]any
|
var tokenRes map[string]any
|
||||||
err := json.Unmarshal(tokenRecorder.Body.Bytes(), &tokenRes)
|
err := json.Unmarshal(tokenRecorder.Body.Bytes(), &tokenRes)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
_, ok := tokenRes["refresh_token"]
|
_, ok := tokenRes["refresh_token"]
|
||||||
assert.True(t, ok, "Expected refresh token in response")
|
assert.True(t, ok, "Expected refresh token in response")
|
||||||
@@ -306,7 +316,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
ClientSecret: "some-client-secret",
|
ClientSecret: "some-client-secret",
|
||||||
}
|
}
|
||||||
reqBodyEncoded, err := query.Values(reqBody)
|
reqBodyEncoded, err := query.Values(reqBody)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
|
req := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
@@ -318,7 +328,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
assert.Equal(t, 200, recorder.Code)
|
assert.Equal(t, 200, recorder.Code)
|
||||||
var refreshRes map[string]any
|
var refreshRes map[string]any
|
||||||
err = json.Unmarshal(recorder.Body.Bytes(), &refreshRes)
|
err = json.Unmarshal(recorder.Body.Bytes(), &refreshRes)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
_, ok = refreshRes["access_token"]
|
_, ok = refreshRes["access_token"]
|
||||||
assert.True(t, ok, "Expected access token in refresh response")
|
assert.True(t, ok, "Expected access token in refresh response")
|
||||||
@@ -339,11 +349,11 @@ func TestOIDCController(t *testing.T) {
|
|||||||
|
|
||||||
var authorizeRes map[string]any
|
var authorizeRes map[string]any
|
||||||
err := json.Unmarshal(authorizeTestRecorder.Body.Bytes(), &authorizeRes)
|
err := json.Unmarshal(authorizeTestRecorder.Body.Bytes(), &authorizeRes)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
redirectURI := authorizeRes["redirect_uri"].(string)
|
redirectURI := authorizeRes["redirect_uri"].(string)
|
||||||
url, err := url.Parse(redirectURI)
|
url, err := url.Parse(redirectURI)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
queryParams := url.Query()
|
queryParams := url.Query()
|
||||||
code := queryParams.Get("code")
|
code := queryParams.Get("code")
|
||||||
@@ -355,7 +365,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
RedirectURI: "https://test.example.com/callback",
|
RedirectURI: "https://test.example.com/callback",
|
||||||
}
|
}
|
||||||
reqBodyEncoded, err := query.Values(reqBody)
|
reqBodyEncoded, err := query.Values(reqBody)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
|
req := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
@@ -375,7 +385,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
|
|
||||||
var secondRes map[string]any
|
var secondRes map[string]any
|
||||||
err = json.Unmarshal(secondRecorder.Body.Bytes(), &secondRes)
|
err = json.Unmarshal(secondRecorder.Body.Bytes(), &secondRes)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, "invalid_grant", secondRes["error"])
|
assert.Equal(t, "invalid_grant", secondRes["error"])
|
||||||
},
|
},
|
||||||
@@ -403,7 +413,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
|
|
||||||
var tokenRes map[string]any
|
var tokenRes map[string]any
|
||||||
err := json.Unmarshal(tokenRecorder.Body.Bytes(), &tokenRes)
|
err := json.Unmarshal(tokenRecorder.Body.Bytes(), &tokenRes)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
accessToken := tokenRes["access_token"].(string)
|
accessToken := tokenRes["access_token"].(string)
|
||||||
assert.NotEmpty(t, accessToken)
|
assert.NotEmpty(t, accessToken)
|
||||||
@@ -415,7 +425,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
|
|
||||||
var userInfoRes map[string]any
|
var userInfoRes map[string]any
|
||||||
err = json.Unmarshal(recorder.Body.Bytes(), &userInfoRes)
|
err = json.Unmarshal(recorder.Body.Bytes(), &userInfoRes)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
_, ok := userInfoRes["sub"]
|
_, ok := userInfoRes["sub"]
|
||||||
assert.True(t, ok, "Expected sub claim in userinfo response")
|
assert.True(t, ok, "Expected sub claim in userinfo response")
|
||||||
@@ -435,7 +445,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
|
|
||||||
var res map[string]any
|
var res map[string]any
|
||||||
err := json.Unmarshal(recorder.Body.Bytes(), &res)
|
err := json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, "invalid_request", res["error"])
|
assert.Equal(t, "invalid_request", res["error"])
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -450,7 +460,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
|
|
||||||
var res map[string]any
|
var res map[string]any
|
||||||
err := json.Unmarshal(recorder.Body.Bytes(), &res)
|
err := json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, "invalid_request", res["error"])
|
assert.Equal(t, "invalid_request", res["error"])
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -465,7 +475,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
|
|
||||||
var res map[string]any
|
var res map[string]any
|
||||||
err := json.Unmarshal(recorder.Body.Bytes(), &res)
|
err := json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, "invalid_request", res["error"])
|
assert.Equal(t, "invalid_request", res["error"])
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -480,7 +490,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
|
|
||||||
var res map[string]any
|
var res map[string]any
|
||||||
err := json.Unmarshal(recorder.Body.Bytes(), &res)
|
err := json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, "invalid_grant", res["error"])
|
assert.Equal(t, "invalid_grant", res["error"])
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -495,7 +505,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
|
|
||||||
var res map[string]any
|
var res map[string]any
|
||||||
err := json.Unmarshal(recorder.Body.Bytes(), &res)
|
err := json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, "invalid_request", res["error"])
|
assert.Equal(t, "invalid_request", res["error"])
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -510,7 +520,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
|
|
||||||
var res map[string]any
|
var res map[string]any
|
||||||
err := json.Unmarshal(recorder.Body.Bytes(), &res)
|
err := json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, "invalid_request", res["error"])
|
assert.Equal(t, "invalid_request", res["error"])
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -527,7 +537,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
|
|
||||||
var tokenRes map[string]any
|
var tokenRes map[string]any
|
||||||
err := json.Unmarshal(tokenRecorder.Body.Bytes(), &tokenRes)
|
err := json.Unmarshal(tokenRecorder.Body.Bytes(), &tokenRes)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
accessToken := tokenRes["access_token"].(string)
|
accessToken := tokenRes["access_token"].(string)
|
||||||
assert.NotEmpty(t, accessToken)
|
assert.NotEmpty(t, accessToken)
|
||||||
@@ -541,7 +551,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
|
|
||||||
var userInfoRes map[string]any
|
var userInfoRes map[string]any
|
||||||
err = json.Unmarshal(recorder.Body.Bytes(), &userInfoRes)
|
err = json.Unmarshal(recorder.Body.Bytes(), &userInfoRes)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
_, ok := userInfoRes["sub"]
|
_, ok := userInfoRes["sub"]
|
||||||
assert.True(t, ok, "Expected sub claim in userinfo response")
|
assert.True(t, ok, "Expected sub claim in userinfo response")
|
||||||
@@ -565,7 +575,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
CodeChallengeMethod: "",
|
CodeChallengeMethod: "",
|
||||||
}
|
}
|
||||||
reqBodyBytes, err := json.Marshal(reqBody)
|
reqBodyBytes, err := json.Marshal(reqBody)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/oidc/authorize", strings.NewReader(string(reqBodyBytes)))
|
req := httptest.NewRequest("POST", "/api/oidc/authorize", strings.NewReader(string(reqBodyBytes)))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
@@ -574,11 +584,11 @@ func TestOIDCController(t *testing.T) {
|
|||||||
|
|
||||||
var res map[string]any
|
var res map[string]any
|
||||||
err = json.Unmarshal(recorder.Body.Bytes(), &res)
|
err = json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
redirectURI := res["redirect_uri"].(string)
|
redirectURI := res["redirect_uri"].(string)
|
||||||
url, err := url.Parse(redirectURI)
|
url, err := url.Parse(redirectURI)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
queryParams := url.Query()
|
queryParams := url.Query()
|
||||||
assert.Equal(t, queryParams.Get("state"), "some-state")
|
assert.Equal(t, queryParams.Get("state"), "some-state")
|
||||||
@@ -595,7 +605,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
CodeVerifier: "some-challenge",
|
CodeVerifier: "some-challenge",
|
||||||
}
|
}
|
||||||
reqBodyEncoded, err := query.Values(tokenReqBody)
|
reqBodyEncoded, err := query.Values(tokenReqBody)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
req = httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
|
req = httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
@@ -626,7 +636,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
CodeChallengeMethod: "S256",
|
CodeChallengeMethod: "S256",
|
||||||
}
|
}
|
||||||
reqBodyBytes, err := json.Marshal(reqBody)
|
reqBodyBytes, err := json.Marshal(reqBody)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/oidc/authorize", strings.NewReader(string(reqBodyBytes)))
|
req := httptest.NewRequest("POST", "/api/oidc/authorize", strings.NewReader(string(reqBodyBytes)))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
@@ -635,11 +645,11 @@ func TestOIDCController(t *testing.T) {
|
|||||||
|
|
||||||
var res map[string]any
|
var res map[string]any
|
||||||
err = json.Unmarshal(recorder.Body.Bytes(), &res)
|
err = json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
redirectURI := res["redirect_uri"].(string)
|
redirectURI := res["redirect_uri"].(string)
|
||||||
url, err := url.Parse(redirectURI)
|
url, err := url.Parse(redirectURI)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
queryParams := url.Query()
|
queryParams := url.Query()
|
||||||
assert.Equal(t, queryParams.Get("state"), "some-state")
|
assert.Equal(t, queryParams.Get("state"), "some-state")
|
||||||
@@ -656,7 +666,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
CodeVerifier: "some-challenge",
|
CodeVerifier: "some-challenge",
|
||||||
}
|
}
|
||||||
reqBodyEncoded, err := query.Values(tokenReqBody)
|
reqBodyEncoded, err := query.Values(tokenReqBody)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
req = httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
|
req = httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
@@ -687,7 +697,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
CodeChallengeMethod: "S256",
|
CodeChallengeMethod: "S256",
|
||||||
}
|
}
|
||||||
reqBodyBytes, err := json.Marshal(reqBody)
|
reqBodyBytes, err := json.Marshal(reqBody)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/oidc/authorize", strings.NewReader(string(reqBodyBytes)))
|
req := httptest.NewRequest("POST", "/api/oidc/authorize", strings.NewReader(string(reqBodyBytes)))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
@@ -696,11 +706,11 @@ func TestOIDCController(t *testing.T) {
|
|||||||
|
|
||||||
var res map[string]any
|
var res map[string]any
|
||||||
err = json.Unmarshal(recorder.Body.Bytes(), &res)
|
err = json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
redirectURI := res["redirect_uri"].(string)
|
redirectURI := res["redirect_uri"].(string)
|
||||||
url, err := url.Parse(redirectURI)
|
url, err := url.Parse(redirectURI)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
queryParams := url.Query()
|
queryParams := url.Query()
|
||||||
assert.Equal(t, queryParams.Get("state"), "some-state")
|
assert.Equal(t, queryParams.Get("state"), "some-state")
|
||||||
@@ -717,7 +727,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
CodeVerifier: "some-challenge-1",
|
CodeVerifier: "some-challenge-1",
|
||||||
}
|
}
|
||||||
reqBodyEncoded, err := query.Values(tokenReqBody)
|
reqBodyEncoded, err := query.Values(tokenReqBody)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
req = httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
|
req = httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
@@ -748,7 +758,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
CodeChallengeMethod: "foo",
|
CodeChallengeMethod: "foo",
|
||||||
}
|
}
|
||||||
reqBodyBytes, err := json.Marshal(reqBody)
|
reqBodyBytes, err := json.Marshal(reqBody)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/oidc/authorize", strings.NewReader(string(reqBodyBytes)))
|
req := httptest.NewRequest("POST", "/api/oidc/authorize", strings.NewReader(string(reqBodyBytes)))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
@@ -757,11 +767,11 @@ func TestOIDCController(t *testing.T) {
|
|||||||
|
|
||||||
var res map[string]any
|
var res map[string]any
|
||||||
err = json.Unmarshal(recorder.Body.Bytes(), &res)
|
err = json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
redirectURI := res["redirect_uri"].(string)
|
redirectURI := res["redirect_uri"].(string)
|
||||||
url, err := url.Parse(redirectURI)
|
url, err := url.Parse(redirectURI)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
queryParams := url.Query()
|
queryParams := url.Query()
|
||||||
error := queryParams.Get("error")
|
error := queryParams.Get("error")
|
||||||
@@ -780,11 +790,11 @@ func TestOIDCController(t *testing.T) {
|
|||||||
|
|
||||||
var res map[string]any
|
var res map[string]any
|
||||||
err := json.Unmarshal(recorder.Body.Bytes(), &res)
|
err := json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
redirectURI := res["redirect_uri"].(string)
|
redirectURI := res["redirect_uri"].(string)
|
||||||
url, err := url.Parse(redirectURI)
|
url, err := url.Parse(redirectURI)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
queryParams := url.Query()
|
queryParams := url.Query()
|
||||||
code := queryParams.Get("code")
|
code := queryParams.Get("code")
|
||||||
@@ -796,7 +806,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
RedirectURI: "https://test.example.com/callback",
|
RedirectURI: "https://test.example.com/callback",
|
||||||
}
|
}
|
||||||
reqBodyEncoded, err := query.Values(reqBody)
|
reqBodyEncoded, err := query.Values(reqBody)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
|
req := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
@@ -807,7 +817,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
assert.Equal(t, 200, recorder.Code)
|
assert.Equal(t, 200, recorder.Code)
|
||||||
|
|
||||||
err = json.Unmarshal(recorder.Body.Bytes(), &res)
|
err = json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
accessToken := res["access_token"].(string)
|
accessToken := res["access_token"].(string)
|
||||||
assert.NotEmpty(t, accessToken)
|
assert.NotEmpty(t, accessToken)
|
||||||
@@ -832,22 +842,20 @@ func TestOIDCController(t *testing.T) {
|
|||||||
assert.Equal(t, 401, recorder.Code)
|
assert.Equal(t, 401, recorder.Code)
|
||||||
|
|
||||||
err = json.Unmarshal(recorder.Body.Bytes(), &res)
|
err = json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, "invalid_grant", res["error"])
|
assert.Equal(t, "invalid_grant", res["error"])
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
app := bootstrap.NewBootstrapApp(cfg)
|
app := bootstrap.NewBootstrapApp(config.Config{})
|
||||||
|
|
||||||
err := app.SetupDatabase()
|
db, err := app.SetupDatabase(path.Join(tempDir, "tinyauth.db"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
queries := repository.New(app.GetDB())
|
queries := repository.New(db)
|
||||||
|
oidcService := service.NewOIDCService(oidcServiceCfg, queries)
|
||||||
wg := &sync.WaitGroup{}
|
err = oidcService.Init()
|
||||||
|
|
||||||
oidcService, err := service.NewOIDCService(log, cfg, runtime, queries, context.TODO(), wg)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
@@ -861,7 +869,8 @@ func TestOIDCController(t *testing.T) {
|
|||||||
group := router.Group("/api")
|
group := router.Group("/api")
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
controller.NewOIDCController(log, oidcService, group)
|
oidcController := controller.NewOIDCController(controllerCfg, oidcService, group)
|
||||||
|
oidcController.SetupRoutes()
|
||||||
|
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
@@ -870,6 +879,7 @@ func TestOIDCController(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
app.GetDB().Close()
|
err = db.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
"github.com/steveiliop56/tinyauth/internal/config"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
"github.com/steveiliop56/tinyauth/internal/service"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/go-querystring/query"
|
"github.com/google/go-querystring/query"
|
||||||
@@ -50,31 +50,29 @@ type ProxyContext struct {
|
|||||||
ProxyType ProxyType
|
ProxyType ProxyType
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProxyController struct {
|
type ProxyControllerConfig struct {
|
||||||
log *logger.Logger
|
AppURL string
|
||||||
runtime model.RuntimeConfig
|
|
||||||
acls *service.AccessControlsService
|
|
||||||
auth *service.AuthService
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewProxyController(
|
type ProxyController struct {
|
||||||
log *logger.Logger,
|
config ProxyControllerConfig
|
||||||
runtime model.RuntimeConfig,
|
router *gin.RouterGroup
|
||||||
router *gin.RouterGroup,
|
acls *service.AccessControlsService
|
||||||
acls *service.AccessControlsService,
|
auth *service.AuthService
|
||||||
auth *service.AuthService,
|
}
|
||||||
) *ProxyController {
|
|
||||||
controller := &ProxyController{
|
func NewProxyController(config ProxyControllerConfig, router *gin.RouterGroup, acls *service.AccessControlsService, auth *service.AuthService) *ProxyController {
|
||||||
log: log,
|
return &ProxyController{
|
||||||
runtime: runtime,
|
config: config,
|
||||||
acls: acls,
|
router: router,
|
||||||
auth: auth,
|
acls: acls,
|
||||||
|
auth: auth,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
proxyGroup := router.Group("/auth")
|
func (controller *ProxyController) SetupRoutes() {
|
||||||
|
proxyGroup := controller.router.Group("/auth")
|
||||||
proxyGroup.Any("/:proxy", controller.proxyHandler)
|
proxyGroup.Any("/:proxy", controller.proxyHandler)
|
||||||
|
|
||||||
return controller
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
||||||
@@ -82,7 +80,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
|||||||
proxyCtx, err := controller.getProxyContext(c)
|
proxyCtx, err := controller.getProxyContext(c)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to get proxy context from request")
|
tlog.App.Warn().Err(err).Msg("Failed to get proxy context")
|
||||||
c.JSON(400, gin.H{
|
c.JSON(400, gin.H{
|
||||||
"status": 400,
|
"status": 400,
|
||||||
"message": "Bad request",
|
"message": "Bad request",
|
||||||
@@ -90,18 +88,22 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tlog.App.Trace().Interface("ctx", proxyCtx).Msg("Got proxy context")
|
||||||
|
|
||||||
// Get acls
|
// Get acls
|
||||||
acls, err := controller.acls.GetAccessControls(proxyCtx.Host)
|
acls, err := controller.acls.GetAccessControls(proxyCtx.Host)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to get ACLs for resource")
|
tlog.App.Error().Err(err).Msg("Failed to get access controls for resource")
|
||||||
controller.handleError(c, proxyCtx)
|
controller.handleError(c, proxyCtx)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tlog.App.Trace().Interface("acls", acls).Msg("ACLs for resource")
|
||||||
|
|
||||||
clientIP := c.ClientIP()
|
clientIP := c.ClientIP()
|
||||||
|
|
||||||
if controller.auth.IsBypassedIP(clientIP, acls) {
|
if controller.auth.IsBypassedIP(acls.IP, clientIP) {
|
||||||
controller.setHeaders(c, acls)
|
controller.setHeaders(c, acls)
|
||||||
c.JSON(200, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"status": 200,
|
"status": 200,
|
||||||
@@ -110,16 +112,16 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
authEnabled, err := controller.auth.IsAuthEnabled(proxyCtx.Path, acls)
|
authEnabled, err := controller.auth.IsAuthEnabled(proxyCtx.Path, acls.Path)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to determine if authentication is enabled for resource")
|
tlog.App.Error().Err(err).Msg("Failed to check if auth is enabled for resource")
|
||||||
controller.handleError(c, proxyCtx)
|
controller.handleError(c, proxyCtx)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !authEnabled {
|
if !authEnabled {
|
||||||
controller.log.App.Debug().Msg("Authentication is disabled for this resource, allowing access without authentication")
|
tlog.App.Debug().Msg("Authentication disabled for resource, allowing access")
|
||||||
controller.setHeaders(c, acls)
|
controller.setHeaders(c, acls)
|
||||||
c.JSON(200, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"status": 200,
|
"status": 200,
|
||||||
@@ -128,19 +130,19 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !controller.auth.CheckIP(clientIP, acls) {
|
if !controller.auth.CheckIP(acls.IP, clientIP) {
|
||||||
queries, err := query.Values(UnauthorizedQuery{
|
queries, err := query.Values(config.UnauthorizedQuery{
|
||||||
Resource: strings.Split(proxyCtx.Host, ".")[0],
|
Resource: strings.Split(proxyCtx.Host, ".")[0],
|
||||||
IP: clientIP,
|
IP: clientIP,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to encode unauthorized query")
|
tlog.App.Error().Err(err).Msg("Failed to encode unauthorized query")
|
||||||
controller.handleError(c, proxyCtx)
|
controller.handleError(c, proxyCtx)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectURL := fmt.Sprintf("%s/unauthorized?%s", controller.runtime.AppURL, queries.Encode())
|
redirectURL := fmt.Sprintf("%s/unauthorized?%s", controller.config.AppURL, queries.Encode())
|
||||||
|
|
||||||
if !controller.useBrowserResponse(proxyCtx) {
|
if !controller.useBrowserResponse(proxyCtx) {
|
||||||
c.Header("x-tinyauth-location", redirectURL)
|
c.Header("x-tinyauth-location", redirectURL)
|
||||||
@@ -155,38 +157,44 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userContext, err := new(model.UserContext).NewFromGin(c)
|
var userContext config.UserContext
|
||||||
|
|
||||||
|
context, err := utils.GetContext(c)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Debug().Err(err).Msg("Failed to create user context from request, treating as unauthenticated")
|
tlog.App.Debug().Msg("No user context found in request, treating as not logged in")
|
||||||
userContext = &model.UserContext{
|
userContext = config.UserContext{
|
||||||
Authenticated: false,
|
IsLoggedIn: false,
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
userContext = context
|
||||||
}
|
}
|
||||||
|
|
||||||
if userContext.Authenticated {
|
tlog.App.Trace().Interface("context", userContext).Msg("User context from request")
|
||||||
userAllowed := controller.auth.IsUserAllowed(c, *userContext, acls)
|
|
||||||
|
if userContext.IsLoggedIn {
|
||||||
|
userAllowed := controller.auth.IsUserAllowed(c, userContext, acls)
|
||||||
|
|
||||||
if !userAllowed {
|
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")
|
tlog.App.Warn().Str("user", userContext.Username).Str("resource", strings.Split(proxyCtx.Host, ".")[0]).Msg("User not allowed to access resource")
|
||||||
|
|
||||||
queries, err := query.Values(UnauthorizedQuery{
|
queries, err := query.Values(config.UnauthorizedQuery{
|
||||||
Resource: strings.Split(proxyCtx.Host, ".")[0],
|
Resource: strings.Split(proxyCtx.Host, ".")[0],
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to encode unauthorized query")
|
tlog.App.Error().Err(err).Msg("Failed to encode unauthorized query")
|
||||||
controller.handleError(c, proxyCtx)
|
controller.handleError(c, proxyCtx)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if userContext.IsOAuth() {
|
if userContext.OAuth {
|
||||||
queries.Set("username", userContext.GetEmail())
|
queries.Set("username", userContext.Email)
|
||||||
} else {
|
} else {
|
||||||
queries.Set("username", userContext.GetUsername())
|
queries.Set("username", userContext.Username)
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectURL := fmt.Sprintf("%s/unauthorized?%s", controller.runtime.AppURL, queries.Encode())
|
redirectURL := fmt.Sprintf("%s/unauthorized?%s", controller.config.AppURL, queries.Encode())
|
||||||
|
|
||||||
if !controller.useBrowserResponse(proxyCtx) {
|
if !controller.useBrowserResponse(proxyCtx) {
|
||||||
c.Header("x-tinyauth-location", redirectURL)
|
c.Header("x-tinyauth-location", redirectURL)
|
||||||
@@ -201,36 +209,36 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if userContext.IsOAuth() || userContext.IsLDAP() {
|
if userContext.OAuth || userContext.Provider == "ldap" {
|
||||||
var groupOK bool
|
var groupOK bool
|
||||||
|
|
||||||
if userContext.IsOAuth() {
|
if userContext.OAuth {
|
||||||
groupOK = controller.auth.IsInOAuthGroup(c, *userContext, acls)
|
groupOK = controller.auth.IsInOAuthGroup(c, userContext, acls.OAuth.Groups)
|
||||||
} else {
|
} else {
|
||||||
groupOK = controller.auth.IsInLDAPGroup(c, *userContext, acls)
|
groupOK = controller.auth.IsInLdapGroup(c, userContext, acls.LDAP.Groups)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !groupOK {
|
if !groupOK {
|
||||||
controller.log.App.Warn().Str("user", userContext.GetUsername()).Str("resource", strings.Split(proxyCtx.Host, ".")[0]).Msg("User is not in the required group to access resource")
|
tlog.App.Warn().Str("user", userContext.Username).Str("resource", strings.Split(proxyCtx.Host, ".")[0]).Msg("User groups do not match resource requirements")
|
||||||
|
|
||||||
queries, err := query.Values(UnauthorizedQuery{
|
queries, err := query.Values(config.UnauthorizedQuery{
|
||||||
Resource: strings.Split(proxyCtx.Host, ".")[0],
|
Resource: strings.Split(proxyCtx.Host, ".")[0],
|
||||||
GroupErr: true,
|
GroupErr: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to encode unauthorized query")
|
tlog.App.Error().Err(err).Msg("Failed to encode unauthorized query")
|
||||||
controller.handleError(c, proxyCtx)
|
controller.handleError(c, proxyCtx)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if userContext.IsOAuth() {
|
if userContext.OAuth {
|
||||||
queries.Set("username", userContext.GetEmail())
|
queries.Set("username", userContext.Email)
|
||||||
} else {
|
} else {
|
||||||
queries.Set("username", userContext.GetUsername())
|
queries.Set("username", userContext.Username)
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectURL := fmt.Sprintf("%s/unauthorized?%s", controller.runtime.AppURL, queries.Encode())
|
redirectURL := fmt.Sprintf("%s/unauthorized?%s", controller.config.AppURL, queries.Encode())
|
||||||
|
|
||||||
if !controller.useBrowserResponse(proxyCtx) {
|
if !controller.useBrowserResponse(proxyCtx) {
|
||||||
c.Header("x-tinyauth-location", redirectURL)
|
c.Header("x-tinyauth-location", redirectURL)
|
||||||
@@ -246,18 +254,17 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Header("Remote-User", utils.SanitizeHeader(userContext.GetUsername()))
|
c.Header("Remote-User", utils.SanitizeHeader(userContext.Username))
|
||||||
c.Header("Remote-Name", utils.SanitizeHeader(userContext.GetName()))
|
c.Header("Remote-Name", utils.SanitizeHeader(userContext.Name))
|
||||||
c.Header("Remote-Email", utils.SanitizeHeader(userContext.GetEmail()))
|
c.Header("Remote-Email", utils.SanitizeHeader(userContext.Email))
|
||||||
|
|
||||||
if userContext.IsLDAP() {
|
if userContext.Provider == "ldap" {
|
||||||
c.Header("Remote-Groups", utils.SanitizeHeader(strings.Join(userContext.LDAP.Groups, ",")))
|
c.Header("Remote-Groups", utils.SanitizeHeader(userContext.LdapGroups))
|
||||||
|
} else if userContext.Provider != "local" {
|
||||||
|
c.Header("Remote-Groups", utils.SanitizeHeader(userContext.OAuthGroups))
|
||||||
}
|
}
|
||||||
|
|
||||||
if userContext.IsOAuth() {
|
c.Header("Remote-Sub", utils.SanitizeHeader(userContext.OAuthSub))
|
||||||
c.Header("Remote-Groups", utils.SanitizeHeader(strings.Join(userContext.OAuth.Groups, ",")))
|
|
||||||
c.Header("Remote-Sub", utils.SanitizeHeader(userContext.OAuth.Sub))
|
|
||||||
}
|
|
||||||
|
|
||||||
controller.setHeaders(c, acls)
|
controller.setHeaders(c, acls)
|
||||||
|
|
||||||
@@ -268,17 +275,17 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
queries, err := query.Values(RedirectQuery{
|
queries, err := query.Values(config.RedirectQuery{
|
||||||
RedirectURI: fmt.Sprintf("%s://%s%s", proxyCtx.Proto, proxyCtx.Host, proxyCtx.Path),
|
RedirectURI: fmt.Sprintf("%s://%s%s", proxyCtx.Proto, proxyCtx.Host, proxyCtx.Path),
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to encode redirect query")
|
tlog.App.Error().Err(err).Msg("Failed to encode redirect URI query")
|
||||||
controller.handleError(c, proxyCtx)
|
controller.handleError(c, proxyCtx)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectURL := fmt.Sprintf("%s/login?%s", controller.runtime.AppURL, queries.Encode())
|
redirectURL := fmt.Sprintf("%s/login?%s", controller.config.AppURL, queries.Encode())
|
||||||
|
|
||||||
if !controller.useBrowserResponse(proxyCtx) {
|
if !controller.useBrowserResponse(proxyCtx) {
|
||||||
c.Header("x-tinyauth-location", redirectURL)
|
c.Header("x-tinyauth-location", redirectURL)
|
||||||
@@ -292,29 +299,26 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
|||||||
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
|
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (controller *ProxyController) setHeaders(c *gin.Context, acls *model.App) {
|
func (controller *ProxyController) setHeaders(c *gin.Context, acls config.App) {
|
||||||
c.Header("Authorization", c.Request.Header.Get("Authorization"))
|
c.Header("Authorization", c.Request.Header.Get("Authorization"))
|
||||||
|
|
||||||
if acls == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
headers := utils.ParseHeaders(acls.Response.Headers)
|
headers := utils.ParseHeaders(acls.Response.Headers)
|
||||||
|
|
||||||
for key, value := range headers {
|
for key, value := range headers {
|
||||||
|
tlog.App.Debug().Str("header", key).Msg("Setting header")
|
||||||
c.Header(key, value)
|
c.Header(key, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
basicPassword := utils.GetSecret(acls.Response.BasicAuth.Password, acls.Response.BasicAuth.PasswordFile)
|
basicPassword := utils.GetSecret(acls.Response.BasicAuth.Password, acls.Response.BasicAuth.PasswordFile)
|
||||||
|
|
||||||
if acls.Response.BasicAuth.Username != "" && basicPassword != "" {
|
if acls.Response.BasicAuth.Username != "" && basicPassword != "" {
|
||||||
controller.log.App.Debug().Msg("Setting basic auth header for response")
|
tlog.App.Debug().Str("username", acls.Response.BasicAuth.Username).Msg("Setting basic auth header")
|
||||||
c.Header("Authorization", fmt.Sprintf("Basic %s", utils.EncodeBasicAuth(acls.Response.BasicAuth.Username, basicPassword)))
|
c.Header("Authorization", fmt.Sprintf("Basic %s", utils.GetBasicAuth(acls.Response.BasicAuth.Username, basicPassword)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (controller *ProxyController) handleError(c *gin.Context, proxyCtx ProxyContext) {
|
func (controller *ProxyController) handleError(c *gin.Context, proxyCtx ProxyContext) {
|
||||||
redirectURL := fmt.Sprintf("%s/error", controller.runtime.AppURL)
|
redirectURL := fmt.Sprintf("%s/error", controller.config.AppURL)
|
||||||
|
|
||||||
if !controller.useBrowserResponse(proxyCtx) {
|
if !controller.useBrowserResponse(proxyCtx) {
|
||||||
c.Header("x-tinyauth-location", redirectURL)
|
c.Header("x-tinyauth-location", redirectURL)
|
||||||
@@ -515,7 +519,7 @@ func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext
|
|||||||
return ProxyContext{}, err
|
return ProxyContext{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
controller.log.App.Debug().Msgf("Determined proxy type: %v", proxy)
|
tlog.App.Debug().Msgf("Proxy: %v", req.Proxy)
|
||||||
|
|
||||||
authModules := controller.determineAuthModules(proxy)
|
authModules := controller.determineAuthModules(proxy)
|
||||||
|
|
||||||
@@ -526,13 +530,13 @@ func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext
|
|||||||
var ctx ProxyContext
|
var ctx ProxyContext
|
||||||
|
|
||||||
for _, module := range authModules {
|
for _, module := range authModules {
|
||||||
controller.log.App.Debug().Msgf("Trying to get context from auth module %v", module)
|
tlog.App.Debug().Msgf("Trying auth module: %v", module)
|
||||||
ctx, err = controller.getContextFromAuthModule(c, module)
|
ctx, err = controller.getContextFromAuthModule(c, module)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
controller.log.App.Debug().Msgf("Successfully got context from auth module %v", module)
|
tlog.App.Debug().Msgf("Auth module %v succeeded", module)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
controller.log.App.Debug().Msgf("Failed to get context from auth module %v: %v", module, err)
|
tlog.App.Debug().Err(err).Msgf("Auth module %v failed", module)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -544,9 +548,9 @@ func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext
|
|||||||
isBrowser := BrowserUserAgentRegex.MatchString(userAgent)
|
isBrowser := BrowserUserAgentRegex.MatchString(userAgent)
|
||||||
|
|
||||||
if isBrowser {
|
if isBrowser {
|
||||||
controller.log.App.Debug().Msg("Request identified as coming from a browser client")
|
tlog.App.Debug().Msg("Request identified as coming from a browser")
|
||||||
} else {
|
} else {
|
||||||
controller.log.App.Debug().Msg("Request identified as coming from a non-browser client")
|
tlog.App.Debug().Msg("Request identified as coming from a non-browser client")
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.IsBrowser = isBrowser
|
ctx.IsBrowser = isBrowser
|
||||||
|
|||||||
@@ -1,50 +1,70 @@
|
|||||||
package controller_test
|
package controller_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"sync"
|
"path"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/bootstrap"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/config"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/controller"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/repository"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/service"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/bootstrap"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/controller"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestProxyController(t *testing.T) {
|
func TestProxyController(t *testing.T) {
|
||||||
log := logger.NewLogger().WithTestConfig()
|
tlog.NewTestLogger().Init()
|
||||||
log.Init()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
cfg, runtime := createTestConfigs(t)
|
authServiceCfg := service.AuthServiceConfig{
|
||||||
|
Users: []config.User{
|
||||||
|
{
|
||||||
|
Username: "testuser",
|
||||||
|
Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Username: "totpuser",
|
||||||
|
Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password
|
||||||
|
TotpSecret: "JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SessionExpiry: 10, // 10 seconds, useful for testing
|
||||||
|
CookieDomain: "example.com",
|
||||||
|
LoginTimeout: 10, // 10 seconds, useful for testing
|
||||||
|
LoginMaxRetries: 3,
|
||||||
|
SessionCookieName: "tinyauth-session",
|
||||||
|
}
|
||||||
|
|
||||||
acls := map[string]model.App{
|
controllerCfg := controller.ProxyControllerConfig{
|
||||||
|
AppURL: "https://tinyauth.example.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
acls := map[string]config.App{
|
||||||
"app_path_allow": {
|
"app_path_allow": {
|
||||||
Config: model.AppConfig{
|
Config: config.AppConfig{
|
||||||
Domain: "path-allow.example.com",
|
Domain: "path-allow.example.com",
|
||||||
},
|
},
|
||||||
Path: model.AppPath{
|
Path: config.AppPath{
|
||||||
Allow: "/allowed",
|
Allow: "/allowed",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"app_user_allow": {
|
"app_user_allow": {
|
||||||
Config: model.AppConfig{
|
Config: config.AppConfig{
|
||||||
Domain: "user-allow.example.com",
|
Domain: "user-allow.example.com",
|
||||||
},
|
},
|
||||||
Users: model.AppUsers{
|
Users: config.AppUsers{
|
||||||
Allow: "testuser",
|
Allow: "testuser",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"ip_bypass": {
|
"ip_bypass": {
|
||||||
Config: model.AppConfig{
|
Config: config.AppConfig{
|
||||||
Domain: "ip-bypass.example.com",
|
Domain: "ip-bypass.example.com",
|
||||||
},
|
},
|
||||||
IP: model.AppIP{
|
IP: config.AppIP{
|
||||||
Bypass: []string{"10.10.10.10"},
|
Bypass: []string{"10.10.10.10"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -54,31 +74,24 @@ func TestProxyController(t *testing.T) {
|
|||||||
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`
|
||||||
|
|
||||||
simpleCtx := func(c *gin.Context) {
|
simpleCtx := func(c *gin.Context) {
|
||||||
c.Set("context", &model.UserContext{
|
c.Set("context", &config.UserContext{
|
||||||
Authenticated: true,
|
Username: "testuser",
|
||||||
Provider: model.ProviderLocal,
|
Name: "Testuser",
|
||||||
Local: &model.LocalContext{
|
Email: "testuser@example.com",
|
||||||
BaseContext: model.BaseContext{
|
IsLoggedIn: true,
|
||||||
Username: "testuser",
|
Provider: "local",
|
||||||
Name: "Testuser",
|
|
||||||
Email: "testuser@example.com",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
|
|
||||||
simpleCtxTotp := func(c *gin.Context) {
|
simpleCtxTotp := func(c *gin.Context) {
|
||||||
c.Set("context", &model.UserContext{
|
c.Set("context", &config.UserContext{
|
||||||
Authenticated: true,
|
Username: "totpuser",
|
||||||
Provider: model.ProviderLocal,
|
Name: "Totpuser",
|
||||||
Local: &model.LocalContext{
|
Email: "totpuser@example.com",
|
||||||
BaseContext: model.BaseContext{
|
IsLoggedIn: true,
|
||||||
Username: "totpuser",
|
Provider: "local",
|
||||||
Name: "Totpuser",
|
TotpEnabled: true,
|
||||||
Email: "totpuser@example.com",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
@@ -378,19 +391,32 @@ func TestProxyController(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
app := bootstrap.NewBootstrapApp(cfg)
|
oauthBrokerCfgs := make(map[string]config.OAuthServiceConfig)
|
||||||
|
|
||||||
err := app.SetupDatabase()
|
app := bootstrap.NewBootstrapApp(config.Config{})
|
||||||
|
|
||||||
|
db, err := app.SetupDatabase(path.Join(tempDir, "tinyauth.db"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
queries := repository.New(app.GetDB())
|
queries := repository.New(db)
|
||||||
|
|
||||||
wg := &sync.WaitGroup{}
|
docker := service.NewDockerService()
|
||||||
ctx := context.TODO()
|
err = docker.Init()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
broker := service.NewOAuthBrokerService(log, map[string]model.OAuthServiceConfig{}, ctx)
|
ldap := service.NewLdapService(service.LdapServiceConfig{})
|
||||||
authService := service.NewAuthService(log, cfg, runtime, ctx, wg, nil, queries, broker)
|
err = ldap.Init()
|
||||||
aclsService := service.NewAccessControlsService(log, nil, acls)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
broker := service.NewOAuthBrokerService(oauthBrokerCfgs)
|
||||||
|
err = broker.Init()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
authService := service.NewAuthService(authServiceCfg, docker, ldap, queries, broker)
|
||||||
|
err = authService.Init()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
aclsService := service.NewAccessControlsService(docker, acls)
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.description, func(t *testing.T) {
|
t.Run(test.description, func(t *testing.T) {
|
||||||
@@ -405,13 +431,15 @@ func TestProxyController(t *testing.T) {
|
|||||||
|
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
controller.NewProxyController(log, runtime, group, aclsService, authService)
|
proxyController := controller.NewProxyController(controllerCfg, group, aclsService, authService)
|
||||||
|
proxyController.SetupRoutes()
|
||||||
|
|
||||||
test.run(t, router, recorder)
|
test.run(t, router, recorder)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
app.GetDB().Close()
|
err = db.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,39 +4,42 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type ResourcesControllerConfig struct {
|
||||||
|
Path string
|
||||||
|
Enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
type ResourcesController struct {
|
type ResourcesController struct {
|
||||||
config model.Config
|
config ResourcesControllerConfig
|
||||||
|
router *gin.RouterGroup
|
||||||
fileServer http.Handler
|
fileServer http.Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewResourcesController(
|
func NewResourcesController(config ResourcesControllerConfig, router *gin.RouterGroup) *ResourcesController {
|
||||||
config model.Config,
|
fileServer := http.StripPrefix("/resources", http.FileServer(http.Dir(config.Path)))
|
||||||
router *gin.RouterGroup,
|
|
||||||
) *ResourcesController {
|
|
||||||
fileServer := http.StripPrefix("/resources", http.FileServer(http.Dir(config.Resources.Path)))
|
|
||||||
|
|
||||||
controller := &ResourcesController{
|
return &ResourcesController{
|
||||||
config: config,
|
config: config,
|
||||||
|
router: router,
|
||||||
fileServer: fileServer,
|
fileServer: fileServer,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
router.GET("/resources/*resource", controller.resourcesHandler)
|
func (controller *ResourcesController) SetupRoutes() {
|
||||||
|
controller.router.GET("/resources/*resource", controller.resourcesHandler)
|
||||||
return controller
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (controller *ResourcesController) resourcesHandler(c *gin.Context) {
|
func (controller *ResourcesController) resourcesHandler(c *gin.Context) {
|
||||||
if controller.config.Resources.Path == "" {
|
if controller.config.Path == "" {
|
||||||
c.JSON(404, gin.H{
|
c.JSON(404, gin.H{
|
||||||
"status": 404,
|
"status": 404,
|
||||||
"message": "Resources not found",
|
"message": "Resources not found",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !controller.config.Resources.Enabled {
|
if !controller.config.Enabled {
|
||||||
c.JSON(403, gin.H{
|
c.JSON(403, gin.H{
|
||||||
"status": 403,
|
"status": 403,
|
||||||
"message": "Resources are disabled",
|
"message": "Resources are disabled",
|
||||||
|
|||||||
@@ -3,19 +3,26 @@ package controller_test
|
|||||||
import (
|
import (
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/controller"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/controller"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestResourcesController(t *testing.T) {
|
func TestResourcesController(t *testing.T) {
|
||||||
cfg, _ := createTestConfigs(t)
|
tlog.NewTestLogger().Init()
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
err := os.MkdirAll(cfg.Resources.Path, 0777)
|
resourcesControllerCfg := controller.ResourcesControllerConfig{
|
||||||
|
Path: path.Join(tempDir, "resources"),
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := os.Mkdir(resourcesControllerCfg.Path, 0777)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
type testCase struct {
|
type testCase struct {
|
||||||
@@ -54,11 +61,11 @@ func TestResourcesController(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
testFilePath := cfg.Resources.Path + "/testfile.txt"
|
testFilePath := resourcesControllerCfg.Path + "/testfile.txt"
|
||||||
err = os.WriteFile(testFilePath, []byte("This is a test file."), 0777)
|
err = os.WriteFile(testFilePath, []byte("This is a test file."), 0777)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
testFilePathParent := filepath.Dir(cfg.Resources.Path) + "/somefile.txt"
|
testFilePathParent := tempDir + "/somefile.txt"
|
||||||
err = os.WriteFile(testFilePathParent, []byte("This file should not be accessible."), 0777)
|
err = os.WriteFile(testFilePathParent, []byte("This file should not be accessible."), 0777)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -68,7 +75,8 @@ func TestResourcesController(t *testing.T) {
|
|||||||
group := router.Group("/")
|
group := router.Group("/")
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
controller.NewResourcesController(cfg, group)
|
resourcesController := controller.NewResourcesController(resourcesControllerCfg, group)
|
||||||
|
resourcesController.SetupRoutes()
|
||||||
|
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
test.run(t, router, recorder)
|
test.run(t, router, recorder)
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
"github.com/steveiliop56/tinyauth/internal/repository"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
"github.com/steveiliop56/tinyauth/internal/service"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/pquerna/otp/totp"
|
"github.com/pquerna/otp/totp"
|
||||||
@@ -25,30 +22,29 @@ type TotpRequest struct {
|
|||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserController struct {
|
type UserControllerConfig struct {
|
||||||
log *logger.Logger
|
CookieDomain string
|
||||||
runtime model.RuntimeConfig
|
|
||||||
auth *service.AuthService
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUserController(
|
type UserController struct {
|
||||||
log *logger.Logger,
|
config UserControllerConfig
|
||||||
runtimeConfig model.RuntimeConfig,
|
router *gin.RouterGroup
|
||||||
router *gin.RouterGroup,
|
auth *service.AuthService
|
||||||
auth *service.AuthService,
|
}
|
||||||
) *UserController {
|
|
||||||
controller := &UserController{
|
|
||||||
log: log,
|
|
||||||
runtime: runtimeConfig,
|
|
||||||
auth: auth,
|
|
||||||
}
|
|
||||||
|
|
||||||
userGroup := router.Group("/user")
|
func NewUserController(config UserControllerConfig, router *gin.RouterGroup, auth *service.AuthService) *UserController {
|
||||||
|
return &UserController{
|
||||||
|
config: config,
|
||||||
|
router: router,
|
||||||
|
auth: auth,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (controller *UserController) SetupRoutes() {
|
||||||
|
userGroup := controller.router.Group("/user")
|
||||||
userGroup.POST("/login", controller.loginHandler)
|
userGroup.POST("/login", controller.loginHandler)
|
||||||
userGroup.POST("/logout", controller.logoutHandler)
|
userGroup.POST("/logout", controller.logoutHandler)
|
||||||
userGroup.POST("/totp", controller.totpHandler)
|
userGroup.POST("/totp", controller.totpHandler)
|
||||||
|
|
||||||
return controller
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (controller *UserController) loginHandler(c *gin.Context) {
|
func (controller *UserController) loginHandler(c *gin.Context) {
|
||||||
@@ -56,7 +52,7 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
|||||||
|
|
||||||
err := c.ShouldBindJSON(&req)
|
err := c.ShouldBindJSON(&req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to bind JSON")
|
tlog.App.Error().Err(err).Msg("Failed to bind JSON")
|
||||||
c.JSON(400, gin.H{
|
c.JSON(400, gin.H{
|
||||||
"status": 400,
|
"status": 400,
|
||||||
"message": "Bad Request",
|
"message": "Bad Request",
|
||||||
@@ -64,13 +60,13 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
controller.log.App.Debug().Str("username", req.Username).Msg("Login attempt")
|
tlog.App.Debug().Str("username", req.Username).Msg("Login attempt")
|
||||||
|
|
||||||
isLocked, remaining := controller.auth.IsAccountLocked(req.Username)
|
isLocked, remaining := controller.auth.IsAccountLocked(req.Username)
|
||||||
|
|
||||||
if isLocked {
|
if isLocked {
|
||||||
controller.log.App.Warn().Str("username", req.Username).Msg("Account is locked due to too many failed login attempts")
|
tlog.App.Warn().Str("username", req.Username).Msg("Account is locked due to too many failed login attempts")
|
||||||
controller.log.AuditLoginFailure(req.Username, "local", c.ClientIP(), "account locked")
|
tlog.AuditLoginFailure(c, req.Username, "username", "account locked")
|
||||||
c.Writer.Header().Add("x-tinyauth-lock-locked", "true")
|
c.Writer.Header().Add("x-tinyauth-lock-locked", "true")
|
||||||
c.Writer.Header().Add("x-tinyauth-lock-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339))
|
c.Writer.Header().Add("x-tinyauth-lock-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339))
|
||||||
c.JSON(429, gin.H{
|
c.JSON(429, gin.H{
|
||||||
@@ -80,35 +76,12 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
search, err := controller.auth.SearchUser(req.Username)
|
userSearch := controller.auth.SearchUser(req.Username)
|
||||||
|
|
||||||
if err != nil {
|
if userSearch.Type == "unknown" {
|
||||||
if errors.Is(err, service.ErrUserNotFound) {
|
tlog.App.Warn().Str("username", req.Username).Msg("User not found")
|
||||||
controller.log.App.Warn().Str("username", req.Username).Msg("User not found during login attempt")
|
|
||||||
controller.auth.RecordLoginAttempt(req.Username, false)
|
|
||||||
controller.log.AuditLoginFailure(req.Username, "unknown", c.ClientIP(), "user not found")
|
|
||||||
c.JSON(401, gin.H{
|
|
||||||
"status": 401,
|
|
||||||
"message": "Unauthorized",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
controller.log.App.Error().Err(err).Str("username", req.Username).Msg("Error searching for user during login attempt")
|
|
||||||
c.JSON(500, gin.H{
|
|
||||||
"status": 500,
|
|
||||||
"message": "Internal Server Error",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := controller.auth.CheckUserPassword(*search, req.Password); err != nil {
|
|
||||||
controller.log.App.Warn().Str("username", req.Username).Msg("Invalid password during login attempt")
|
|
||||||
controller.auth.RecordLoginAttempt(req.Username, false)
|
controller.auth.RecordLoginAttempt(req.Username, false)
|
||||||
if search.Type == model.UserLocal {
|
tlog.AuditLoginFailure(c, req.Username, "username", "user not found")
|
||||||
controller.log.AuditLoginFailure(req.Username, "local", c.ClientIP(), "invalid password")
|
|
||||||
} else {
|
|
||||||
controller.log.AuditLoginFailure(req.Username, "ldap", c.ClientIP(), "invalid password")
|
|
||||||
}
|
|
||||||
c.JSON(401, gin.H{
|
c.JSON(401, gin.H{
|
||||||
"status": 401,
|
"status": 401,
|
||||||
"message": "Unauthorized",
|
"message": "Unauthorized",
|
||||||
@@ -116,43 +89,38 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var localUser *model.LocalUser
|
if !controller.auth.VerifyUser(userSearch, req.Password) {
|
||||||
|
tlog.App.Warn().Str("username", req.Username).Msg("Invalid password")
|
||||||
|
controller.auth.RecordLoginAttempt(req.Username, false)
|
||||||
|
tlog.AuditLoginFailure(c, req.Username, "username", "invalid password")
|
||||||
|
c.JSON(401, gin.H{
|
||||||
|
"status": 401,
|
||||||
|
"message": "Unauthorized",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if search.Type == model.UserLocal {
|
tlog.App.Info().Str("username", req.Username).Msg("Login successful")
|
||||||
localUser = controller.auth.GetLocalUser(req.Username)
|
tlog.AuditLoginSuccess(c, req.Username, "username")
|
||||||
|
|
||||||
if localUser == nil {
|
controller.auth.RecordLoginAttempt(req.Username, true)
|
||||||
controller.log.App.Error().Str("username", req.Username).Msg("Local user not found after successful password verification")
|
|
||||||
c.JSON(401, gin.H{
|
|
||||||
"status": 401,
|
|
||||||
"message": "Unauthorized",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if localUser.TOTPSecret != "" {
|
if userSearch.Type == "local" {
|
||||||
controller.log.App.Debug().Str("username", req.Username).Msg("TOTP required for user, creating pending TOTP session")
|
user := controller.auth.GetLocalUser(userSearch.Username)
|
||||||
|
|
||||||
name := localUser.Attributes.Name
|
if user.TotpSecret != "" {
|
||||||
if name == "" {
|
tlog.App.Debug().Str("username", req.Username).Msg("User has TOTP enabled, requiring TOTP verification")
|
||||||
name = utils.Capitalize(localUser.Username)
|
|
||||||
}
|
|
||||||
|
|
||||||
email := localUser.Attributes.Email
|
err := controller.auth.CreateSessionCookie(c, &repository.Session{
|
||||||
if email == "" {
|
Username: user.Username,
|
||||||
email = utils.CompileUserEmail(localUser.Username, controller.runtime.CookieDomain)
|
Name: utils.Capitalize(user.Username),
|
||||||
}
|
Email: utils.CompileUserEmail(user.Username, controller.config.CookieDomain),
|
||||||
|
|
||||||
cookie, err := controller.auth.CreateSession(c, repository.Session{
|
|
||||||
Username: localUser.Username,
|
|
||||||
Name: name,
|
|
||||||
Email: email,
|
|
||||||
Provider: "local",
|
Provider: "local",
|
||||||
TotpPending: true,
|
TotpPending: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Str("username", req.Username).Msg("Failed to create pending TOTP session")
|
tlog.App.Error().Err(err).Msg("Failed to create session cookie")
|
||||||
c.JSON(500, gin.H{
|
c.JSON(500, gin.H{
|
||||||
"status": 500,
|
"status": 500,
|
||||||
"message": "Internal Server Error",
|
"message": "Internal Server Error",
|
||||||
@@ -160,8 +128,6 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
http.SetCookie(c.Writer, cookie)
|
|
||||||
|
|
||||||
c.JSON(200, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"message": "TOTP required",
|
"message": "TOTP required",
|
||||||
@@ -174,27 +140,20 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
|||||||
sessionCookie := repository.Session{
|
sessionCookie := repository.Session{
|
||||||
Username: req.Username,
|
Username: req.Username,
|
||||||
Name: utils.Capitalize(req.Username),
|
Name: utils.Capitalize(req.Username),
|
||||||
Email: utils.CompileUserEmail(req.Username, controller.runtime.CookieDomain),
|
Email: utils.CompileUserEmail(req.Username, controller.config.CookieDomain),
|
||||||
Provider: "local",
|
Provider: "local",
|
||||||
}
|
}
|
||||||
|
|
||||||
if search.Type == model.UserLocal {
|
if userSearch.Type == "ldap" {
|
||||||
if localUser.Attributes.Name != "" {
|
|
||||||
sessionCookie.Name = localUser.Attributes.Name
|
|
||||||
}
|
|
||||||
if localUser.Attributes.Email != "" {
|
|
||||||
sessionCookie.Email = localUser.Attributes.Email
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if search.Type == model.UserLDAP {
|
|
||||||
sessionCookie.Provider = "ldap"
|
sessionCookie.Provider = "ldap"
|
||||||
}
|
}
|
||||||
|
|
||||||
cookie, err := controller.auth.CreateSession(c, sessionCookie)
|
tlog.App.Trace().Interface("session_cookie", sessionCookie).Msg("Creating session cookie")
|
||||||
|
|
||||||
|
err = controller.auth.CreateSessionCookie(c, &sessionCookie)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Str("username", req.Username).Msg("Failed to create session cookie after successful login")
|
tlog.App.Error().Err(err).Msg("Failed to create session cookie")
|
||||||
c.JSON(500, gin.H{
|
c.JSON(500, gin.H{
|
||||||
"status": 500,
|
"status": 500,
|
||||||
"message": "Internal Server Error",
|
"message": "Internal Server Error",
|
||||||
@@ -202,18 +161,6 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
http.SetCookie(c.Writer, cookie)
|
|
||||||
|
|
||||||
controller.log.App.Info().Str("username", req.Username).Msg("Login successful")
|
|
||||||
|
|
||||||
if search.Type == model.UserLocal {
|
|
||||||
controller.log.AuditLoginSuccess(req.Username, "local", c.ClientIP())
|
|
||||||
} else {
|
|
||||||
controller.log.AuditLoginSuccess(req.Username, "ldap", c.ClientIP())
|
|
||||||
}
|
|
||||||
|
|
||||||
controller.auth.RecordLoginAttempt(req.Username, true)
|
|
||||||
|
|
||||||
c.JSON(200, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"message": "Login successful",
|
"message": "Login successful",
|
||||||
@@ -221,49 +168,15 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (controller *UserController) logoutHandler(c *gin.Context) {
|
func (controller *UserController) logoutHandler(c *gin.Context) {
|
||||||
controller.log.App.Debug().Msg("Logout attempt")
|
tlog.App.Debug().Msg("Logout request received")
|
||||||
|
|
||||||
uuid, err := c.Cookie(controller.runtime.SessionCookieName)
|
controller.auth.DeleteSessionCookie(c)
|
||||||
|
|
||||||
if err != nil {
|
context, err := utils.GetContext(c)
|
||||||
if errors.Is(err, http.ErrNoCookie) {
|
if err == nil && context.IsLoggedIn {
|
||||||
controller.log.App.Warn().Msg("Logout attempt without session cookie, treating as successful logout")
|
tlog.AuditLogout(c, context.Username, context.Provider)
|
||||||
c.JSON(200, gin.H{
|
|
||||||
"status": 200,
|
|
||||||
"message": "Logout successful",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
controller.log.App.Error().Err(err).Msg("Error retrieving session cookie on logout")
|
|
||||||
c.JSON(500, gin.H{
|
|
||||||
"status": 500,
|
|
||||||
"message": "Internal Server Error",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cookie, err := controller.auth.DeleteSession(c, uuid)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
controller.log.App.Error().Err(err).Msg("Error deleting session on logout")
|
|
||||||
c.JSON(500, gin.H{
|
|
||||||
"status": 500,
|
|
||||||
"message": "Internal Server Error",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
context, err := new(model.UserContext).NewFromGin(c)
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
controller.log.AuditLogout(context.GetUsername(), context.GetProviderID(), c.ClientIP())
|
|
||||||
} else {
|
|
||||||
controller.log.App.Warn().Err(err).Msg("Failed to get user context during logout, logging audit with unknown user")
|
|
||||||
controller.log.AuditLogout("unknown", "unknown", c.ClientIP())
|
|
||||||
}
|
|
||||||
|
|
||||||
http.SetCookie(c.Writer, cookie)
|
|
||||||
|
|
||||||
c.JSON(200, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"message": "Logout successful",
|
"message": "Logout successful",
|
||||||
@@ -275,7 +188,7 @@ func (controller *UserController) totpHandler(c *gin.Context) {
|
|||||||
|
|
||||||
err := c.ShouldBindJSON(&req)
|
err := c.ShouldBindJSON(&req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to bind JSON for TOTP verification")
|
tlog.App.Error().Err(err).Msg("Failed to bind JSON")
|
||||||
c.JSON(400, gin.H{
|
c.JSON(400, gin.H{
|
||||||
"status": 400,
|
"status": 400,
|
||||||
"message": "Bad Request",
|
"message": "Bad Request",
|
||||||
@@ -283,10 +196,10 @@ func (controller *UserController) totpHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
context, err := new(model.UserContext).NewFromGin(c)
|
context, err := utils.GetContext(c)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to create user context from request for TOTP verification")
|
tlog.App.Error().Err(err).Msg("Failed to get user context")
|
||||||
c.JSON(500, gin.H{
|
c.JSON(500, gin.H{
|
||||||
"status": 500,
|
"status": 500,
|
||||||
"message": "Internal Server Error",
|
"message": "Internal Server Error",
|
||||||
@@ -294,8 +207,8 @@ func (controller *UserController) totpHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !context.TOTPPending() {
|
if !context.TotpPending {
|
||||||
controller.log.App.Warn().Str("username", context.GetUsername()).Msg("TOTP verification attempt without pending TOTP session")
|
tlog.App.Warn().Msg("TOTP attempt without a pending TOTP session")
|
||||||
c.JSON(401, gin.H{
|
c.JSON(401, gin.H{
|
||||||
"status": 401,
|
"status": 401,
|
||||||
"message": "Unauthorized",
|
"message": "Unauthorized",
|
||||||
@@ -303,13 +216,12 @@ func (controller *UserController) totpHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
controller.log.App.Debug().Str("username", context.GetUsername()).Msg("TOTP verification attempt")
|
tlog.App.Debug().Str("username", context.Username).Msg("TOTP verification attempt")
|
||||||
|
|
||||||
isLocked, remaining := controller.auth.IsAccountLocked(context.GetUsername())
|
isLocked, remaining := controller.auth.IsAccountLocked(context.Username)
|
||||||
|
|
||||||
if isLocked {
|
if isLocked {
|
||||||
controller.log.App.Warn().Str("username", context.GetUsername()).Msg("Account is locked due to too many failed TOTP attempts")
|
tlog.App.Warn().Str("username", context.Username).Msg("Account is locked due to too many failed TOTP attempts")
|
||||||
controller.log.AuditLoginFailure(context.GetUsername(), "local", c.ClientIP(), "account locked")
|
|
||||||
c.Writer.Header().Add("x-tinyauth-lock-locked", "true")
|
c.Writer.Header().Add("x-tinyauth-lock-locked", "true")
|
||||||
c.Writer.Header().Add("x-tinyauth-lock-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339))
|
c.Writer.Header().Add("x-tinyauth-lock-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339))
|
||||||
c.JSON(429, gin.H{
|
c.JSON(429, gin.H{
|
||||||
@@ -319,23 +231,14 @@ func (controller *UserController) totpHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user := controller.auth.GetLocalUser(context.GetUsername())
|
user := controller.auth.GetLocalUser(context.Username)
|
||||||
|
|
||||||
if user == nil {
|
ok := totp.Validate(req.Code, user.TotpSecret)
|
||||||
controller.log.App.Error().Str("username", context.GetUsername()).Msg("Local user not found during TOTP verification")
|
|
||||||
c.JSON(401, gin.H{
|
|
||||||
"status": 401,
|
|
||||||
"message": "Unauthorized",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ok := totp.Validate(req.Code, user.TOTPSecret)
|
|
||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
controller.log.App.Warn().Str("username", context.GetUsername()).Msg("Invalid TOTP code during verification attempt")
|
tlog.App.Warn().Str("username", context.Username).Msg("Invalid TOTP code")
|
||||||
controller.auth.RecordLoginAttempt(context.GetUsername(), false)
|
controller.auth.RecordLoginAttempt(context.Username, false)
|
||||||
controller.log.AuditLoginFailure(context.GetUsername(), "local", c.ClientIP(), "invalid TOTP code")
|
tlog.AuditLoginFailure(c, context.Username, "totp", "invalid totp code")
|
||||||
c.JSON(401, gin.H{
|
c.JSON(401, gin.H{
|
||||||
"status": 401,
|
"status": 401,
|
||||||
"message": "Unauthorized",
|
"message": "Unauthorized",
|
||||||
@@ -343,37 +246,24 @@ func (controller *UserController) totpHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
uuid, err := c.Cookie(controller.runtime.SessionCookieName)
|
tlog.App.Info().Str("username", context.Username).Msg("TOTP verification successful")
|
||||||
|
tlog.AuditLoginSuccess(c, context.Username, "totp")
|
||||||
|
|
||||||
if err == nil {
|
controller.auth.RecordLoginAttempt(context.Username, true)
|
||||||
_, err = controller.auth.DeleteSession(c, uuid)
|
|
||||||
if err != nil {
|
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to delete pending TOTP session after successful verification")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
controller.log.App.Warn().Err(err).Msg("Failed to retrieve session cookie for pending TOTP session, cannot delete it")
|
|
||||||
}
|
|
||||||
|
|
||||||
controller.auth.RecordLoginAttempt(context.GetUsername(), true)
|
|
||||||
|
|
||||||
sessionCookie := repository.Session{
|
sessionCookie := repository.Session{
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
Name: utils.Capitalize(user.Username),
|
Name: utils.Capitalize(user.Username),
|
||||||
Email: utils.CompileUserEmail(user.Username, controller.runtime.CookieDomain),
|
Email: utils.CompileUserEmail(user.Username, controller.config.CookieDomain),
|
||||||
Provider: "local",
|
Provider: "local",
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.Attributes.Name != "" {
|
tlog.App.Trace().Interface("session_cookie", sessionCookie).Msg("Creating session cookie")
|
||||||
sessionCookie.Name = user.Attributes.Name
|
|
||||||
}
|
|
||||||
if user.Attributes.Email != "" {
|
|
||||||
sessionCookie.Email = user.Attributes.Email
|
|
||||||
}
|
|
||||||
|
|
||||||
cookie, err := controller.auth.CreateSession(c, sessionCookie)
|
err = controller.auth.CreateSessionCookie(c, &sessionCookie)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Str("username", context.GetUsername()).Msg("Failed to create session cookie after successful TOTP verification")
|
tlog.App.Error().Err(err).Msg("Failed to create session cookie")
|
||||||
c.JSON(500, gin.H{
|
c.JSON(500, gin.H{
|
||||||
"status": 500,
|
"status": 500,
|
||||||
"message": "Internal Server Error",
|
"message": "Internal Server Error",
|
||||||
@@ -381,11 +271,6 @@ func (controller *UserController) totpHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
http.SetCookie(c.Writer, cookie)
|
|
||||||
|
|
||||||
controller.log.App.Info().Str("username", context.GetUsername()).Msg("TOTP verification successful, login complete")
|
|
||||||
controller.log.AuditLoginSuccess(context.GetUsername(), "local", c.ClientIP())
|
|
||||||
|
|
||||||
c.JSON(200, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"message": "Login successful",
|
"message": "Login successful",
|
||||||
|
|||||||
@@ -1,84 +1,53 @@
|
|||||||
package controller_test
|
package controller_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"path"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/pquerna/otp/totp"
|
"github.com/pquerna/otp/totp"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/bootstrap"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/config"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/controller"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/repository"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/service"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/bootstrap"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/controller"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestUserController(t *testing.T) {
|
func TestUserController(t *testing.T) {
|
||||||
log := logger.NewLogger().WithTestConfig()
|
tlog.NewTestLogger().Init()
|
||||||
log.Init()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
cfg, runtime := createTestConfigs(t)
|
authServiceCfg := service.AuthServiceConfig{
|
||||||
|
Users: []config.User{
|
||||||
totpCtx := func(c *gin.Context) {
|
{
|
||||||
c.Set("context", &model.UserContext{
|
Username: "testuser",
|
||||||
Authenticated: false,
|
Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password
|
||||||
Provider: model.ProviderLocal,
|
|
||||||
Local: &model.LocalContext{
|
|
||||||
BaseContext: model.BaseContext{
|
|
||||||
Username: "totpuser",
|
|
||||||
Name: "Totpuser",
|
|
||||||
Email: "totpuser@example.com",
|
|
||||||
},
|
|
||||||
TOTPPending: true,
|
|
||||||
},
|
},
|
||||||
})
|
{
|
||||||
|
Username: "totpuser",
|
||||||
|
Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password
|
||||||
|
TotpSecret: "JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SessionExpiry: 10, // 10 seconds, useful for testing
|
||||||
|
CookieDomain: "example.com",
|
||||||
|
LoginTimeout: 10, // 10 seconds, useful for testing
|
||||||
|
LoginMaxRetries: 3,
|
||||||
|
SessionCookieName: "tinyauth-session",
|
||||||
}
|
}
|
||||||
|
|
||||||
totpAttrCtx := func(c *gin.Context) {
|
userControllerCfg := controller.UserControllerConfig{
|
||||||
c.Set("context", &model.UserContext{
|
CookieDomain: "example.com",
|
||||||
Authenticated: false,
|
|
||||||
Provider: model.ProviderLocal,
|
|
||||||
Local: &model.LocalContext{
|
|
||||||
BaseContext: model.BaseContext{
|
|
||||||
Username: "attrtotpuser",
|
|
||||||
Name: "Bob Jones",
|
|
||||||
Email: "bob@example.com",
|
|
||||||
},
|
|
||||||
TOTPPending: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
simpleCtx := func(c *gin.Context) {
|
|
||||||
c.Set("context", &model.UserContext{
|
|
||||||
Authenticated: true,
|
|
||||||
Provider: model.ProviderLocal,
|
|
||||||
Local: &model.LocalContext{
|
|
||||||
BaseContext: model.BaseContext{
|
|
||||||
Username: "testuser",
|
|
||||||
Name: "Test User",
|
|
||||||
Email: "testuser@example.com",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
app := bootstrap.NewBootstrapApp(cfg)
|
|
||||||
|
|
||||||
err := app.SetupDatabase()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
queries := repository.New(app.GetDB())
|
|
||||||
|
|
||||||
type testCase struct {
|
type testCase struct {
|
||||||
description string
|
description string
|
||||||
middlewares []gin.HandlerFunc
|
middlewares []gin.HandlerFunc
|
||||||
@@ -95,7 +64,7 @@ func TestUserController(t *testing.T) {
|
|||||||
Password: "password",
|
Password: "password",
|
||||||
}
|
}
|
||||||
loginReqBody, err := json.Marshal(loginReq)
|
loginReqBody, err := json.Marshal(loginReq)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqBody)))
|
req := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqBody)))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
@@ -103,15 +72,13 @@ func TestUserController(t *testing.T) {
|
|||||||
router.ServeHTTP(recorder, req)
|
router.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
assert.Equal(t, 200, recorder.Code)
|
assert.Equal(t, 200, recorder.Code)
|
||||||
require.Len(t, recorder.Result().Cookies(), 1)
|
assert.Len(t, recorder.Result().Cookies(), 1)
|
||||||
|
|
||||||
cookie := recorder.Result().Cookies()[0]
|
cookie := recorder.Result().Cookies()[0]
|
||||||
assert.Equal(t, "tinyauth-session", cookie.Name)
|
assert.Equal(t, "tinyauth-session", cookie.Name)
|
||||||
assert.True(t, cookie.HttpOnly)
|
assert.True(t, cookie.HttpOnly)
|
||||||
assert.Equal(t, "example.com", cookie.Domain)
|
assert.Equal(t, "example.com", cookie.Domain)
|
||||||
// 3 seconds should be more than enough for even slow test environments
|
assert.Equal(t, 10, cookie.MaxAge)
|
||||||
assert.GreaterOrEqual(t, cookie.MaxAge, 7)
|
|
||||||
assert.LessOrEqual(t, cookie.MaxAge, 10)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -123,7 +90,7 @@ func TestUserController(t *testing.T) {
|
|||||||
Password: "wrongpassword",
|
Password: "wrongpassword",
|
||||||
}
|
}
|
||||||
loginReqBody, err := json.Marshal(loginReq)
|
loginReqBody, err := json.Marshal(loginReq)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqBody)))
|
req := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqBody)))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
@@ -144,7 +111,7 @@ func TestUserController(t *testing.T) {
|
|||||||
Password: "wrongpassword",
|
Password: "wrongpassword",
|
||||||
}
|
}
|
||||||
loginReqBody, err := json.Marshal(loginReq)
|
loginReqBody, err := json.Marshal(loginReq)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
for range 3 {
|
for range 3 {
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
@@ -179,7 +146,7 @@ func TestUserController(t *testing.T) {
|
|||||||
Password: "password",
|
Password: "password",
|
||||||
}
|
}
|
||||||
loginReqBody, err := json.Marshal(loginReq)
|
loginReqBody, err := json.Marshal(loginReq)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqBody)))
|
req := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqBody)))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
@@ -190,25 +157,22 @@ func TestUserController(t *testing.T) {
|
|||||||
|
|
||||||
decodedBody := make(map[string]any)
|
decodedBody := make(map[string]any)
|
||||||
err = json.Unmarshal(recorder.Body.Bytes(), &decodedBody)
|
err = json.Unmarshal(recorder.Body.Bytes(), &decodedBody)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, decodedBody["totpPending"], true)
|
assert.Equal(t, decodedBody["totpPending"], true)
|
||||||
|
|
||||||
// should set the session cookie
|
// should set the session cookie
|
||||||
require.Len(t, recorder.Result().Cookies(), 1)
|
assert.Len(t, recorder.Result().Cookies(), 1)
|
||||||
cookie := recorder.Result().Cookies()[0]
|
cookie := recorder.Result().Cookies()[0]
|
||||||
assert.Equal(t, "tinyauth-session", cookie.Name)
|
assert.Equal(t, "tinyauth-session", cookie.Name)
|
||||||
assert.True(t, cookie.HttpOnly)
|
assert.True(t, cookie.HttpOnly)
|
||||||
assert.Equal(t, "example.com", cookie.Domain)
|
assert.Equal(t, "example.com", cookie.Domain)
|
||||||
assert.GreaterOrEqual(t, cookie.MaxAge, 3597)
|
assert.Equal(t, 3600, cookie.MaxAge) // 1 hour, default for totp pending sessions
|
||||||
assert.LessOrEqual(t, cookie.MaxAge, 3600)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "Should be able to logout",
|
description: "Should be able to logout",
|
||||||
middlewares: []gin.HandlerFunc{
|
middlewares: []gin.HandlerFunc{},
|
||||||
simpleCtx,
|
|
||||||
},
|
|
||||||
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||||
// First login to get a session cookie
|
// First login to get a session cookie
|
||||||
loginReq := controller.LoginRequest{
|
loginReq := controller.LoginRequest{
|
||||||
@@ -216,7 +180,7 @@ func TestUserController(t *testing.T) {
|
|||||||
Password: "password",
|
Password: "password",
|
||||||
}
|
}
|
||||||
loginReqBody, err := json.Marshal(loginReq)
|
loginReqBody, err := json.Marshal(loginReq)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqBody)))
|
req := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqBody)))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
@@ -224,10 +188,9 @@ func TestUserController(t *testing.T) {
|
|||||||
router.ServeHTTP(recorder, req)
|
router.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
assert.Equal(t, 200, recorder.Code)
|
assert.Equal(t, 200, recorder.Code)
|
||||||
cookies := recorder.Result().Cookies()
|
assert.Len(t, recorder.Result().Cookies(), 1)
|
||||||
require.Len(t, cookies, 1)
|
|
||||||
|
|
||||||
cookie := cookies[0]
|
cookie := recorder.Result().Cookies()[0]
|
||||||
assert.Equal(t, "tinyauth-session", cookie.Name)
|
assert.Equal(t, "tinyauth-session", cookie.Name)
|
||||||
|
|
||||||
// Now logout using the session cookie
|
// Now logout using the session cookie
|
||||||
@@ -238,72 +201,48 @@ func TestUserController(t *testing.T) {
|
|||||||
router.ServeHTTP(recorder, req)
|
router.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
assert.Equal(t, 200, recorder.Code)
|
assert.Equal(t, 200, recorder.Code)
|
||||||
cookies = recorder.Result().Cookies()
|
assert.Len(t, recorder.Result().Cookies(), 1)
|
||||||
require.Len(t, cookies, 1)
|
|
||||||
|
|
||||||
cookie = cookies[0]
|
logoutCookie := recorder.Result().Cookies()[0]
|
||||||
assert.Equal(t, "tinyauth-session", cookie.Name)
|
assert.Equal(t, "tinyauth-session", logoutCookie.Name)
|
||||||
assert.Equal(t, "", cookie.Value)
|
assert.Equal(t, "", logoutCookie.Value)
|
||||||
assert.Equal(t, -1, cookie.MaxAge) // MaxAge -1 means delete cookie
|
assert.Equal(t, -1, logoutCookie.MaxAge) // MaxAge -1 means delete cookie
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "Should be able to login with totp",
|
description: "Should be able to login with totp",
|
||||||
middlewares: []gin.HandlerFunc{
|
middlewares: []gin.HandlerFunc{},
|
||||||
totpCtx,
|
|
||||||
},
|
|
||||||
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||||
_, err := queries.CreateSession(context.TODO(), repository.CreateSessionParams{
|
|
||||||
UUID: "test-totp-login-uuid",
|
|
||||||
Username: "test",
|
|
||||||
Email: "test@example.com",
|
|
||||||
Name: "Test",
|
|
||||||
Provider: "local",
|
|
||||||
TotpPending: true,
|
|
||||||
Expiry: time.Now().Add(1 * time.Hour).Unix(),
|
|
||||||
CreatedAt: time.Now().Unix(),
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
code, err := totp.GenerateCode("JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK", time.Now())
|
code, err := totp.GenerateCode("JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK", time.Now())
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
totpReq := controller.TotpRequest{
|
totpReq := controller.TotpRequest{
|
||||||
Code: code,
|
Code: code,
|
||||||
}
|
}
|
||||||
|
|
||||||
totpReqBody, err := json.Marshal(totpReq)
|
totpReqBody, err := json.Marshal(totpReq)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
recorder = httptest.NewRecorder()
|
recorder = httptest.NewRecorder()
|
||||||
req := httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(totpReqBody)))
|
req := httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(totpReqBody)))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.AddCookie(&http.Cookie{
|
|
||||||
Name: "tinyauth-session",
|
|
||||||
Value: "test-totp-login-uuid",
|
|
||||||
HttpOnly: true,
|
|
||||||
MaxAge: 3600,
|
|
||||||
Expires: time.Now().Add(1 * time.Hour),
|
|
||||||
})
|
|
||||||
router.ServeHTTP(recorder, req)
|
router.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
assert.Equal(t, 200, recorder.Code)
|
assert.Equal(t, 200, recorder.Code)
|
||||||
require.Len(t, recorder.Result().Cookies(), 1)
|
assert.Len(t, recorder.Result().Cookies(), 1)
|
||||||
|
|
||||||
// should set a new session cookie with totp pending removed
|
// should set a new session cookie with totp pending removed
|
||||||
totpCookie := recorder.Result().Cookies()[0]
|
totpCookie := recorder.Result().Cookies()[0]
|
||||||
assert.Equal(t, "tinyauth-session", totpCookie.Name)
|
assert.Equal(t, "tinyauth-session", totpCookie.Name)
|
||||||
assert.True(t, totpCookie.HttpOnly)
|
assert.True(t, totpCookie.HttpOnly)
|
||||||
assert.Equal(t, "example.com", totpCookie.Domain)
|
assert.Equal(t, "example.com", totpCookie.Domain)
|
||||||
assert.GreaterOrEqual(t, totpCookie.MaxAge, 7)
|
assert.Equal(t, 10, totpCookie.MaxAge) // should use the regular session expiry time
|
||||||
assert.LessOrEqual(t, totpCookie.MaxAge, 10)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "Totp should rate limit on multiple invalid attempts",
|
description: "Totp should rate limit on multiple invalid attempts",
|
||||||
middlewares: []gin.HandlerFunc{
|
middlewares: []gin.HandlerFunc{},
|
||||||
totpCtx,
|
|
||||||
},
|
|
||||||
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||||
for range 3 {
|
for range 3 {
|
||||||
totpReq := controller.TotpRequest{
|
totpReq := controller.TotpRequest{
|
||||||
@@ -311,7 +250,7 @@ func TestUserController(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
totpReqBody, err := json.Marshal(totpReq)
|
totpReqBody, err := json.Marshal(totpReq)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
recorder = httptest.NewRecorder()
|
recorder = httptest.NewRecorder()
|
||||||
req := httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(totpReqBody)))
|
req := httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(totpReqBody)))
|
||||||
@@ -334,98 +273,43 @@ func TestUserController(t *testing.T) {
|
|||||||
assert.Contains(t, recorder.Body.String(), "Too many failed TOTP attempts.")
|
assert.Contains(t, recorder.Body.String(), "Too many failed TOTP attempts.")
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
description: "Login uses name and email from user attributes",
|
|
||||||
middlewares: []gin.HandlerFunc{},
|
|
||||||
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
|
||||||
loginReq := controller.LoginRequest{Username: "attruser", Password: "password"}
|
|
||||||
body, err := json.Marshal(loginReq)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(body)))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
router.ServeHTTP(recorder, req)
|
|
||||||
|
|
||||||
require.Equal(t, 200, recorder.Code)
|
|
||||||
cookies := recorder.Result().Cookies()
|
|
||||||
require.Len(t, cookies, 1)
|
|
||||||
assert.Equal(t, "tinyauth-session", cookies[0].Name)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Login with TOTP uses name and email from user attributes in pending session",
|
|
||||||
middlewares: []gin.HandlerFunc{},
|
|
||||||
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
|
||||||
loginReq := controller.LoginRequest{Username: "attrtotpuser", Password: "password"}
|
|
||||||
body, err := json.Marshal(loginReq)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(body)))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
router.ServeHTTP(recorder, req)
|
|
||||||
|
|
||||||
require.Equal(t, 200, recorder.Code)
|
|
||||||
var res map[string]any
|
|
||||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &res))
|
|
||||||
assert.Equal(t, true, res["totpPending"])
|
|
||||||
require.Len(t, recorder.Result().Cookies(), 1)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "TOTP completion uses name and email from user attributes",
|
|
||||||
middlewares: []gin.HandlerFunc{
|
|
||||||
totpAttrCtx,
|
|
||||||
},
|
|
||||||
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
|
||||||
_, err := queries.CreateSession(context.TODO(), repository.CreateSessionParams{
|
|
||||||
UUID: "test-totp-login-attributes-uuid",
|
|
||||||
Username: "test",
|
|
||||||
Email: "test@example.com",
|
|
||||||
Name: "Test",
|
|
||||||
Provider: "local",
|
|
||||||
TotpPending: true,
|
|
||||||
Expiry: time.Now().Add(1 * time.Hour).Unix(),
|
|
||||||
CreatedAt: time.Now().Unix(),
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
code, err := totp.GenerateCode("JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK", time.Now())
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
totpReq := controller.TotpRequest{Code: code}
|
|
||||||
body, err := json.Marshal(totpReq)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(body)))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.AddCookie(&http.Cookie{
|
|
||||||
Name: "tinyauth-session",
|
|
||||||
Value: "test-totp-login-attributes-uuid",
|
|
||||||
HttpOnly: true,
|
|
||||||
MaxAge: 3600,
|
|
||||||
Expires: time.Now().Add(1 * time.Hour),
|
|
||||||
})
|
|
||||||
router.ServeHTTP(recorder, req)
|
|
||||||
|
|
||||||
require.Equal(t, 200, recorder.Code)
|
|
||||||
cookies := recorder.Result().Cookies()
|
|
||||||
require.Len(t, cookies, 1)
|
|
||||||
assert.Equal(t, "tinyauth-session", cookies[0].Name)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.TODO()
|
oauthBrokerCfgs := make(map[string]config.OAuthServiceConfig)
|
||||||
wg := &sync.WaitGroup{}
|
|
||||||
|
|
||||||
broker := service.NewOAuthBrokerService(log, map[string]model.OAuthServiceConfig{}, ctx)
|
app := bootstrap.NewBootstrapApp(config.Config{})
|
||||||
authService := service.NewAuthService(log, cfg, runtime, ctx, wg, nil, queries, broker)
|
|
||||||
|
db, err := app.SetupDatabase(path.Join(tempDir, "tinyauth.db"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
queries := repository.New(db)
|
||||||
|
|
||||||
|
docker := service.NewDockerService()
|
||||||
|
err = docker.Init()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ldap := service.NewLdapService(service.LdapServiceConfig{})
|
||||||
|
err = ldap.Init()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
broker := service.NewOAuthBrokerService(oauthBrokerCfgs)
|
||||||
|
err = broker.Init()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
authService := service.NewAuthService(authServiceCfg, docker, ldap, queries, broker)
|
||||||
|
err = authService.Init()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
beforeEach := func() {
|
beforeEach := func() {
|
||||||
// Clear failed login attempts before each test
|
// Clear failed login attempts before each test
|
||||||
authService.ClearRateLimitsTestingOnly()
|
authService.ClearRateLimitsTestingOnly()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setTotpMiddlewareOverrides := []string{
|
||||||
|
"Should be able to login with totp",
|
||||||
|
"Totp should rate limit on multiple invalid attempts",
|
||||||
|
}
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
beforeEach()
|
beforeEach()
|
||||||
t.Run(test.description, func(t *testing.T) {
|
t.Run(test.description, func(t *testing.T) {
|
||||||
@@ -435,10 +319,28 @@ func TestUserController(t *testing.T) {
|
|||||||
router.Use(middleware)
|
router.Use(middleware)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gin is stupid and doesn't allow setting a middleware after the groups
|
||||||
|
// so we need to do some stupid overrides here
|
||||||
|
if slices.Contains(setTotpMiddlewareOverrides, test.description) {
|
||||||
|
// Assuming the cookie is set, it should be picked up by the
|
||||||
|
// context middleware
|
||||||
|
router.Use(func(c *gin.Context) {
|
||||||
|
c.Set("context", &config.UserContext{
|
||||||
|
Username: "totpuser",
|
||||||
|
Name: "Totpuser",
|
||||||
|
Email: "totpuser@example.com",
|
||||||
|
Provider: "local",
|
||||||
|
TotpPending: true,
|
||||||
|
TotpEnabled: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
group := router.Group("/api")
|
group := router.Group("/api")
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
controller.NewUserController(log, runtime, group, authService)
|
userController := controller.NewUserController(userControllerCfg, group, authService)
|
||||||
|
userController.SetupRoutes()
|
||||||
|
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
@@ -447,6 +349,7 @@ func TestUserController(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
app.GetDB().Close()
|
err = db.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
"github.com/steveiliop56/tinyauth/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
type OpenIDConnectConfiguration struct {
|
type OpenIDConnectConfiguration struct {
|
||||||
@@ -26,30 +26,28 @@ type OpenIDConnectConfiguration struct {
|
|||||||
RequestObjectSigningAlgValuesSupported []string `json:"request_object_signing_alg_values_supported"`
|
RequestObjectSigningAlgValuesSupported []string `json:"request_object_signing_alg_values_supported"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WellKnownControllerConfig struct{}
|
||||||
|
|
||||||
type WellKnownController struct {
|
type WellKnownController struct {
|
||||||
oidc *service.OIDCService
|
config WellKnownControllerConfig
|
||||||
|
engine *gin.Engine
|
||||||
|
oidc *service.OIDCService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWellKnownController(oidc *service.OIDCService, router *gin.RouterGroup) *WellKnownController {
|
func NewWellKnownController(config WellKnownControllerConfig, oidc *service.OIDCService, engine *gin.Engine) *WellKnownController {
|
||||||
controller := &WellKnownController{
|
return &WellKnownController{
|
||||||
oidc: oidc,
|
config: config,
|
||||||
|
oidc: oidc,
|
||||||
|
engine: engine,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
router.GET("/.well-known/openid-configuration", controller.OpenIDConnectConfiguration)
|
func (controller *WellKnownController) SetupRoutes() {
|
||||||
router.GET("/.well-known/jwks.json", controller.JWKS)
|
controller.engine.GET("/.well-known/openid-configuration", controller.OpenIDConnectConfiguration)
|
||||||
|
controller.engine.GET("/.well-known/jwks.json", controller.JWKS)
|
||||||
return controller
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (controller *WellKnownController) OpenIDConnectConfiguration(c *gin.Context) {
|
func (controller *WellKnownController) OpenIDConnectConfiguration(c *gin.Context) {
|
||||||
if controller.oidc == nil {
|
|
||||||
c.JSON(500, gin.H{
|
|
||||||
"status": 500,
|
|
||||||
"message": "OIDC service not configured",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
issuer := controller.oidc.GetIssuer()
|
issuer := controller.oidc.GetIssuer()
|
||||||
c.JSON(200, OpenIDConnectConfiguration{
|
c.JSON(200, OpenIDConnectConfiguration{
|
||||||
Issuer: issuer,
|
Issuer: issuer,
|
||||||
@@ -63,7 +61,7 @@ func (controller *WellKnownController) OpenIDConnectConfiguration(c *gin.Context
|
|||||||
SubjectTypesSupported: []string{"pairwise"},
|
SubjectTypesSupported: []string{"pairwise"},
|
||||||
IDTokenSigningAlgValuesSupported: []string{"RS256"},
|
IDTokenSigningAlgValuesSupported: []string{"RS256"},
|
||||||
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post"},
|
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post"},
|
||||||
ClaimsSupported: []string{"sub", "updated_at", "name", "preferred_username", "email", "email_verified", "groups", "phone_number", "phone_number_verified", "address", "given_name", "family_name", "middle_name", "nickname", "profile", "picture", "website", "gender", "birthdate", "zoneinfo", "locale"},
|
ClaimsSupported: []string{"sub", "updated_at", "name", "preferred_username", "email", "email_verified", "groups"},
|
||||||
ServiceDocumentation: "https://tinyauth.app/docs/guides/oidc",
|
ServiceDocumentation: "https://tinyauth.app/docs/guides/oidc",
|
||||||
RequestParameterSupported: true,
|
RequestParameterSupported: true,
|
||||||
RequestObjectSigningAlgValuesSupported: []string{"none"},
|
RequestObjectSigningAlgValuesSupported: []string{"none"},
|
||||||
@@ -71,19 +69,11 @@ func (controller *WellKnownController) OpenIDConnectConfiguration(c *gin.Context
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (controller *WellKnownController) JWKS(c *gin.Context) {
|
func (controller *WellKnownController) JWKS(c *gin.Context) {
|
||||||
if controller.oidc == nil {
|
|
||||||
c.JSON(500, gin.H{
|
|
||||||
"status": 500,
|
|
||||||
"message": "OIDC service not configured",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
jwks, err := controller.oidc.GetJWK()
|
jwks, err := controller.oidc.GetJWK()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(500, gin.H{
|
c.JSON(500, gin.H{
|
||||||
"status": 500,
|
"status": "500",
|
||||||
"message": "failed to get JWK",
|
"message": "failed to get JWK",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,28 +1,41 @@
|
|||||||
package controller_test
|
package controller_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"sync"
|
"path"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/bootstrap"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/config"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/controller"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/repository"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/service"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/bootstrap"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/controller"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWellKnownController(t *testing.T) {
|
func TestWellKnownController(t *testing.T) {
|
||||||
log := logger.NewLogger().WithTestConfig()
|
tlog.NewTestLogger().Init()
|
||||||
log.Init()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
cfg, runtime := createTestConfigs(t)
|
oidcServiceCfg := service.OIDCServiceConfig{
|
||||||
|
Clients: map[string]config.OIDCClientConfig{
|
||||||
|
"test": {
|
||||||
|
ClientID: "some-client-id",
|
||||||
|
ClientSecret: "some-client-secret",
|
||||||
|
TrustedRedirectURIs: []string{"https://test.example.com/callback"},
|
||||||
|
Name: "Test Client",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PrivateKeyPath: path.Join(tempDir, "key.pem"),
|
||||||
|
PublicKeyPath: path.Join(tempDir, "key.pub"),
|
||||||
|
Issuer: "https://tinyauth.example.com",
|
||||||
|
SessionExpiry: 500,
|
||||||
|
}
|
||||||
|
|
||||||
type testCase struct {
|
type testCase struct {
|
||||||
description string
|
description string
|
||||||
@@ -43,18 +56,18 @@ func TestWellKnownController(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
expected := controller.OpenIDConnectConfiguration{
|
expected := controller.OpenIDConnectConfiguration{
|
||||||
Issuer: runtime.AppURL,
|
Issuer: oidcServiceCfg.Issuer,
|
||||||
AuthorizationEndpoint: fmt.Sprintf("%s/authorize", runtime.AppURL),
|
AuthorizationEndpoint: fmt.Sprintf("%s/authorize", oidcServiceCfg.Issuer),
|
||||||
TokenEndpoint: fmt.Sprintf("%s/api/oidc/token", runtime.AppURL),
|
TokenEndpoint: fmt.Sprintf("%s/api/oidc/token", oidcServiceCfg.Issuer),
|
||||||
UserinfoEndpoint: fmt.Sprintf("%s/api/oidc/userinfo", runtime.AppURL),
|
UserinfoEndpoint: fmt.Sprintf("%s/api/oidc/userinfo", oidcServiceCfg.Issuer),
|
||||||
JwksUri: fmt.Sprintf("%s/.well-known/jwks.json", runtime.AppURL),
|
JwksUri: fmt.Sprintf("%s/.well-known/jwks.json", oidcServiceCfg.Issuer),
|
||||||
ScopesSupported: service.SupportedScopes,
|
ScopesSupported: service.SupportedScopes,
|
||||||
ResponseTypesSupported: service.SupportedResponseTypes,
|
ResponseTypesSupported: service.SupportedResponseTypes,
|
||||||
GrantTypesSupported: service.SupportedGrantTypes,
|
GrantTypesSupported: service.SupportedGrantTypes,
|
||||||
SubjectTypesSupported: []string{"pairwise"},
|
SubjectTypesSupported: []string{"pairwise"},
|
||||||
IDTokenSigningAlgValuesSupported: []string{"RS256"},
|
IDTokenSigningAlgValuesSupported: []string{"RS256"},
|
||||||
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post"},
|
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post"},
|
||||||
ClaimsSupported: []string{"sub", "updated_at", "name", "preferred_username", "email", "email_verified", "groups", "phone_number", "phone_number_verified", "address", "given_name", "family_name", "middle_name", "nickname", "profile", "picture", "website", "gender", "birthdate", "zoneinfo", "locale"},
|
ClaimsSupported: []string{"sub", "updated_at", "name", "preferred_username", "email", "email_verified", "groups"},
|
||||||
ServiceDocumentation: "https://tinyauth.app/docs/guides/oidc",
|
ServiceDocumentation: "https://tinyauth.app/docs/guides/oidc",
|
||||||
RequestParameterSupported: true,
|
RequestParameterSupported: true,
|
||||||
RequestObjectSigningAlgValuesSupported: []string{"none"},
|
RequestObjectSigningAlgValuesSupported: []string{"none"},
|
||||||
@@ -88,17 +101,16 @@ func TestWellKnownController(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.TODO()
|
app := bootstrap.NewBootstrapApp(config.Config{})
|
||||||
wg := &sync.WaitGroup{}
|
|
||||||
|
|
||||||
app := bootstrap.NewBootstrapApp(cfg)
|
db, err := app.SetupDatabase(path.Join(tempDir, "tinyauth.db"))
|
||||||
|
|
||||||
err := app.SetupDatabase()
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
queries := repository.New(app.GetDB())
|
queries := repository.New(db)
|
||||||
|
|
||||||
oidcService, err := service.NewOIDCService(log, cfg, runtime, queries, ctx, wg)
|
oidcService := service.NewOIDCService(oidcServiceCfg, queries)
|
||||||
|
err = oidcService.Init()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.description, func(t *testing.T) {
|
t.Run(test.description, func(t *testing.T) {
|
||||||
@@ -107,13 +119,15 @@ func TestWellKnownController(t *testing.T) {
|
|||||||
|
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
controller.NewWellKnownController(oidcService, &router.RouterGroup)
|
wellKnownController := controller.NewWellKnownController(controller.WellKnownControllerConfig{}, oidcService, router)
|
||||||
|
wellKnownController.SetupRoutes()
|
||||||
|
|
||||||
test.run(t, router, recorder)
|
test.run(t, router, recorder)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
app.GetDB().Close()
|
err = db.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
"github.com/steveiliop56/tinyauth/internal/config"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
"github.com/steveiliop56/tinyauth/internal/service"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -35,27 +32,28 @@ var (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
type ContextMiddleware struct {
|
type ContextMiddlewareConfig struct {
|
||||||
log *logger.Logger
|
CookieDomain string
|
||||||
runtime model.RuntimeConfig
|
|
||||||
auth *service.AuthService
|
|
||||||
broker *service.OAuthBrokerService
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewContextMiddleware(
|
type ContextMiddleware struct {
|
||||||
log *logger.Logger,
|
config ContextMiddlewareConfig
|
||||||
runtime model.RuntimeConfig,
|
auth *service.AuthService
|
||||||
auth *service.AuthService,
|
broker *service.OAuthBrokerService
|
||||||
broker *service.OAuthBrokerService,
|
}
|
||||||
) *ContextMiddleware {
|
|
||||||
|
func NewContextMiddleware(config ContextMiddlewareConfig, auth *service.AuthService, broker *service.OAuthBrokerService) *ContextMiddleware {
|
||||||
return &ContextMiddleware{
|
return &ContextMiddleware{
|
||||||
log: log,
|
config: config,
|
||||||
runtime: runtime,
|
auth: auth,
|
||||||
auth: auth,
|
broker: broker,
|
||||||
broker: broker,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *ContextMiddleware) Init() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
|
func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
if m.isIgnorePath(c.Request.Method + " " + c.Request.URL.Path) {
|
if m.isIgnorePath(c.Request.Method + " " + c.Request.URL.Path) {
|
||||||
@@ -63,41 +61,177 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
uuid, err := c.Cookie(m.runtime.SessionCookieName)
|
cookie, err := m.auth.GetSessionCookie(c)
|
||||||
|
|
||||||
if err == nil {
|
if err != nil {
|
||||||
userContext, cookie, err := m.cookieAuth(c.Request.Context(), uuid)
|
tlog.App.Debug().Err(err).Msg("No valid session cookie found")
|
||||||
|
goto basic
|
||||||
if err == nil {
|
|
||||||
if cookie != nil {
|
|
||||||
http.SetCookie(c.Writer, cookie)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.log.App.Debug().Msgf("Authenticated user %s via session cookie", userContext.GetUsername())
|
|
||||||
c.Set("context", userContext)
|
|
||||||
c.Next()
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
m.log.App.Debug().Msgf("Error authenticating session cookie: %v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
username, password, ok := c.Request.BasicAuth()
|
if cookie.TotpPending {
|
||||||
|
c.Set("context", &config.UserContext{
|
||||||
|
Username: cookie.Username,
|
||||||
|
Name: cookie.Name,
|
||||||
|
Email: cookie.Email,
|
||||||
|
Provider: "local",
|
||||||
|
TotpPending: true,
|
||||||
|
TotpEnabled: true,
|
||||||
|
})
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if ok {
|
switch cookie.Provider {
|
||||||
userContext, headers, err := m.basicAuth(username, password)
|
case "local", "ldap":
|
||||||
|
userSearch := m.auth.SearchUser(cookie.Username)
|
||||||
|
|
||||||
if err != nil {
|
if userSearch.Type == "unknown" {
|
||||||
m.log.App.Error().Msgf("Error authenticating basic auth: %v", err)
|
tlog.App.Debug().Msg("User from session cookie not found")
|
||||||
|
m.auth.DeleteSessionCookie(c)
|
||||||
|
goto basic
|
||||||
|
}
|
||||||
|
|
||||||
|
if userSearch.Type != cookie.Provider {
|
||||||
|
tlog.App.Warn().Msg("User type from session cookie does not match user search type")
|
||||||
|
m.auth.DeleteSessionCookie(c)
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for k, v := range headers {
|
var ldapGroups []string
|
||||||
c.Header(k, v)
|
|
||||||
|
if cookie.Provider == "ldap" {
|
||||||
|
ldapUser, err := m.auth.GetLdapUser(userSearch.Username)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
tlog.App.Error().Err(err).Msg("Error retrieving LDAP user details")
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ldapGroups = ldapUser.Groups
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Set("context", userContext)
|
m.auth.RefreshSessionCookie(c)
|
||||||
|
c.Set("context", &config.UserContext{
|
||||||
|
Username: cookie.Username,
|
||||||
|
Name: cookie.Name,
|
||||||
|
Email: cookie.Email,
|
||||||
|
Provider: cookie.Provider,
|
||||||
|
IsLoggedIn: true,
|
||||||
|
LdapGroups: strings.Join(ldapGroups, ","),
|
||||||
|
})
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
_, exists := m.broker.GetService(cookie.Provider)
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
tlog.App.Debug().Msg("OAuth provider from session cookie not found")
|
||||||
|
m.auth.DeleteSessionCookie(c)
|
||||||
|
goto basic
|
||||||
|
}
|
||||||
|
|
||||||
|
if !m.auth.IsEmailWhitelisted(cookie.Email) {
|
||||||
|
tlog.App.Debug().Msg("Email from session cookie not whitelisted")
|
||||||
|
m.auth.DeleteSessionCookie(c)
|
||||||
|
goto basic
|
||||||
|
}
|
||||||
|
|
||||||
|
m.auth.RefreshSessionCookie(c)
|
||||||
|
c.Set("context", &config.UserContext{
|
||||||
|
Username: cookie.Username,
|
||||||
|
Name: cookie.Name,
|
||||||
|
Email: cookie.Email,
|
||||||
|
Provider: cookie.Provider,
|
||||||
|
OAuthGroups: cookie.OAuthGroups,
|
||||||
|
OAuthName: cookie.OAuthName,
|
||||||
|
OAuthSub: cookie.OAuthSub,
|
||||||
|
IsLoggedIn: true,
|
||||||
|
OAuth: true,
|
||||||
|
})
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
basic:
|
||||||
|
basic := m.auth.GetBasicAuth(c)
|
||||||
|
|
||||||
|
if basic == nil {
|
||||||
|
tlog.App.Debug().Msg("No basic auth provided")
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
locked, remaining := m.auth.IsAccountLocked(basic.Username)
|
||||||
|
|
||||||
|
if locked {
|
||||||
|
tlog.App.Debug().Msgf("Account for user %s is locked for %d seconds, denying auth", basic.Username, remaining)
|
||||||
|
c.Writer.Header().Add("x-tinyauth-lock-locked", "true")
|
||||||
|
c.Writer.Header().Add("x-tinyauth-lock-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339))
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userSearch := m.auth.SearchUser(basic.Username)
|
||||||
|
|
||||||
|
if userSearch.Type == "unknown" || userSearch.Type == "error" {
|
||||||
|
m.auth.RecordLoginAttempt(basic.Username, false)
|
||||||
|
tlog.App.Debug().Msg("User from basic auth not found")
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !m.auth.VerifyUser(userSearch, basic.Password) {
|
||||||
|
m.auth.RecordLoginAttempt(basic.Username, false)
|
||||||
|
tlog.App.Debug().Msg("Invalid password for basic auth user")
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.auth.RecordLoginAttempt(basic.Username, true)
|
||||||
|
|
||||||
|
switch userSearch.Type {
|
||||||
|
case "local":
|
||||||
|
tlog.App.Debug().Msg("Basic auth user is local")
|
||||||
|
|
||||||
|
user := m.auth.GetLocalUser(basic.Username)
|
||||||
|
|
||||||
|
if user.TotpSecret != "" {
|
||||||
|
tlog.App.Debug().Msg("User with TOTP not allowed to login via basic auth")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set("context", &config.UserContext{
|
||||||
|
Username: user.Username,
|
||||||
|
Name: utils.Capitalize(user.Username),
|
||||||
|
Email: utils.CompileUserEmail(user.Username, m.config.CookieDomain),
|
||||||
|
Provider: "local",
|
||||||
|
IsLoggedIn: true,
|
||||||
|
IsBasicAuth: true,
|
||||||
|
})
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
case "ldap":
|
||||||
|
tlog.App.Debug().Msg("Basic auth user is LDAP")
|
||||||
|
|
||||||
|
ldapUser, err := m.auth.GetLdapUser(basic.Username)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
tlog.App.Debug().Err(err).Msg("Error retrieving LDAP user details")
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set("context", &config.UserContext{
|
||||||
|
Username: basic.Username,
|
||||||
|
Name: utils.Capitalize(basic.Username),
|
||||||
|
Email: utils.CompileUserEmail(basic.Username, m.config.CookieDomain),
|
||||||
|
Provider: "ldap",
|
||||||
|
IsLoggedIn: true,
|
||||||
|
LdapGroups: strings.Join(ldapUser.Groups, ","),
|
||||||
|
IsBasicAuth: true,
|
||||||
|
})
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -106,149 +240,6 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid string) (*model.UserContext, *http.Cookie, error) {
|
|
||||||
session, err := m.auth.GetSession(ctx, uuid)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("error retrieving session: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
userContext, err := new(model.UserContext).NewFromSession(session)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("error creating user context from session: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if userContext.Provider == model.ProviderLocal &&
|
|
||||||
userContext.Local.TOTPPending {
|
|
||||||
return userContext, nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
switch userContext.Provider {
|
|
||||||
case model.ProviderLocal:
|
|
||||||
user := m.auth.GetLocalUser(userContext.Local.Username)
|
|
||||||
|
|
||||||
if user == nil {
|
|
||||||
return nil, nil, fmt.Errorf("local user not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
userContext.Local.Attributes = user.Attributes
|
|
||||||
|
|
||||||
if userContext.Local.Attributes.Name == "" {
|
|
||||||
userContext.Local.Attributes.Name = utils.Capitalize(user.Username)
|
|
||||||
}
|
|
||||||
|
|
||||||
if userContext.Local.Attributes.Email == "" {
|
|
||||||
userContext.Local.Attributes.Email = utils.CompileUserEmail(user.Username, m.runtime.CookieDomain)
|
|
||||||
}
|
|
||||||
case model.ProviderLDAP:
|
|
||||||
search, err := m.auth.SearchUser(userContext.LDAP.Username)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("error searching for ldap user: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if search.Type != model.UserLDAP {
|
|
||||||
return nil, nil, fmt.Errorf("user from session cookie is not ldap")
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := m.auth.GetLDAPUser(search.Username)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("error retrieving ldap user details: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
userContext.LDAP.Groups = user.Groups
|
|
||||||
userContext.LDAP.Name = utils.Capitalize(userContext.LDAP.Username)
|
|
||||||
userContext.LDAP.Email = utils.CompileUserEmail(userContext.LDAP.Username, m.runtime.CookieDomain)
|
|
||||||
case model.ProviderOAuth:
|
|
||||||
_, exists := m.broker.GetService(userContext.OAuth.ID)
|
|
||||||
|
|
||||||
if !exists {
|
|
||||||
return nil, nil, fmt.Errorf("oauth provider from session cookie not found: %s", userContext.OAuth.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !m.auth.IsEmailWhitelisted(userContext.OAuth.Email) {
|
|
||||||
m.auth.DeleteSession(ctx, uuid)
|
|
||||||
return nil, nil, fmt.Errorf("email from session cookie not whitelisted: %s", userContext.OAuth.Email)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cookie, err := m.auth.RefreshSession(ctx, uuid)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("error refreshing session: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return userContext, cookie, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *ContextMiddleware) basicAuth(username string, password string) (*model.UserContext, map[string]string, error) {
|
|
||||||
headers := make(map[string]string)
|
|
||||||
userContext := new(model.UserContext)
|
|
||||||
locked, remaining := m.auth.IsAccountLocked(username)
|
|
||||||
|
|
||||||
if locked {
|
|
||||||
m.log.App.Debug().Msgf("Account for user %s is locked for %d seconds, denying auth", username, remaining)
|
|
||||||
headers["x-tinyauth-lock-locked"] = "true"
|
|
||||||
headers["x-tinyauth-lock-reset"] = time.Now().Add(time.Duration(remaining) * time.Second).Format(time.RFC3339)
|
|
||||||
return nil, headers, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
search, err := m.auth.SearchUser(username)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("error searching for user: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = m.auth.CheckUserPassword(*search, password)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
m.auth.RecordLoginAttempt(username, false)
|
|
||||||
return nil, nil, fmt.Errorf("invalid password for basic auth user: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.auth.RecordLoginAttempt(username, true)
|
|
||||||
|
|
||||||
switch search.Type {
|
|
||||||
case model.UserLocal:
|
|
||||||
user := m.auth.GetLocalUser(username)
|
|
||||||
|
|
||||||
if user.TOTPSecret != "" {
|
|
||||||
return nil, nil, fmt.Errorf("user with totp not allowed to login via basic auth: %s", username)
|
|
||||||
}
|
|
||||||
|
|
||||||
userContext.Local = &model.LocalContext{
|
|
||||||
BaseContext: model.BaseContext{
|
|
||||||
Username: user.Username,
|
|
||||||
Name: utils.Capitalize(user.Username),
|
|
||||||
Email: utils.CompileUserEmail(user.Username, m.runtime.CookieDomain),
|
|
||||||
},
|
|
||||||
Attributes: user.Attributes,
|
|
||||||
}
|
|
||||||
userContext.Provider = model.ProviderLocal
|
|
||||||
case model.UserLDAP:
|
|
||||||
user, err := m.auth.GetLDAPUser(username)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("error retrieving ldap user details: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
userContext.LDAP = &model.LDAPContext{
|
|
||||||
BaseContext: model.BaseContext{
|
|
||||||
Username: username,
|
|
||||||
Name: utils.Capitalize(username),
|
|
||||||
Email: utils.CompileUserEmail(username, m.runtime.CookieDomain),
|
|
||||||
},
|
|
||||||
Groups: user.Groups,
|
|
||||||
}
|
|
||||||
userContext.Provider = model.ProviderLDAP
|
|
||||||
}
|
|
||||||
|
|
||||||
userContext.Authenticated = true
|
|
||||||
return userContext, nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *ContextMiddleware) isIgnorePath(path string) bool {
|
func (m *ContextMiddleware) isIgnorePath(path string) bool {
|
||||||
for _, prefix := range contextSkipPathsPrefix {
|
for _, prefix := range contextSkipPathsPrefix {
|
||||||
if strings.HasPrefix(path, prefix) {
|
if strings.HasPrefix(path, prefix) {
|
||||||
|
|||||||
@@ -1,295 +0,0 @@
|
|||||||
package middleware_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/base64"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/bootstrap"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/middleware"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestContextMiddleware(t *testing.T) {
|
|
||||||
log := logger.NewLogger().WithTestConfig()
|
|
||||||
log.Init()
|
|
||||||
|
|
||||||
cfg, runtime := createTestConfigs(t)
|
|
||||||
|
|
||||||
basicAuthHeader := func(username, password string) string {
|
|
||||||
return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password))
|
|
||||||
}
|
|
||||||
|
|
||||||
seedSession := func(t *testing.T, queries *repository.Queries, params repository.CreateSessionParams) {
|
|
||||||
t.Helper()
|
|
||||||
_, err := queries.CreateSession(context.Background(), params)
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
type runArgs struct {
|
|
||||||
do func(req *http.Request) (*model.UserContext, *httptest.ResponseRecorder)
|
|
||||||
queries *repository.Queries
|
|
||||||
}
|
|
||||||
|
|
||||||
type testCase struct {
|
|
||||||
description string
|
|
||||||
run func(t *testing.T, args runArgs)
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []testCase{
|
|
||||||
{
|
|
||||||
description: "Skip path bypasses auth processing",
|
|
||||||
run: func(t *testing.T, args runArgs) {
|
|
||||||
req := httptest.NewRequest("GET", "/api/healthz", nil)
|
|
||||||
req.Header.Set("Authorization", basicAuthHeader("testuser", "password"))
|
|
||||||
userCtx, _ := args.do(req)
|
|
||||||
|
|
||||||
assert.Nil(t, userCtx)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "No credentials yields no context",
|
|
||||||
run: func(t *testing.T, args runArgs) {
|
|
||||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
||||||
userCtx, _ := args.do(req)
|
|
||||||
|
|
||||||
assert.Nil(t, userCtx)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Valid session cookie sets authenticated local context",
|
|
||||||
run: func(t *testing.T, args runArgs) {
|
|
||||||
uuid := "session-valid-local"
|
|
||||||
seedSession(t, args.queries, repository.CreateSessionParams{
|
|
||||||
UUID: uuid,
|
|
||||||
Username: "testuser",
|
|
||||||
Provider: "local",
|
|
||||||
Expiry: time.Now().Add(10 * time.Second).Unix(),
|
|
||||||
CreatedAt: time.Now().Unix(),
|
|
||||||
})
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
||||||
req.AddCookie(&http.Cookie{Name: "tinyauth-session", Value: uuid})
|
|
||||||
userCtx, _ := args.do(req)
|
|
||||||
|
|
||||||
require.NotNil(t, userCtx)
|
|
||||||
assert.Equal(t, model.ProviderLocal, userCtx.Provider)
|
|
||||||
assert.Equal(t, "testuser", userCtx.GetUsername())
|
|
||||||
assert.True(t, userCtx.Authenticated)
|
|
||||||
require.NotNil(t, userCtx.Local)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Session cookie with totp pending sets unauthenticated context with totp enabled",
|
|
||||||
run: func(t *testing.T, args runArgs) {
|
|
||||||
uuid := "session-totp-pending"
|
|
||||||
seedSession(t, args.queries, repository.CreateSessionParams{
|
|
||||||
UUID: uuid,
|
|
||||||
Username: "totpuser",
|
|
||||||
Provider: "local",
|
|
||||||
TotpPending: true,
|
|
||||||
Expiry: time.Now().Add(60 * time.Second).Unix(),
|
|
||||||
CreatedAt: time.Now().Unix(),
|
|
||||||
})
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
||||||
req.AddCookie(&http.Cookie{Name: "tinyauth-session", Value: uuid})
|
|
||||||
userCtx, _ := args.do(req)
|
|
||||||
|
|
||||||
require.NotNil(t, userCtx)
|
|
||||||
assert.Equal(t, "totpuser", userCtx.GetUsername())
|
|
||||||
assert.False(t, userCtx.Authenticated)
|
|
||||||
require.NotNil(t, userCtx.Local)
|
|
||||||
assert.True(t, userCtx.Local.TOTPPending)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Unknown session cookie yields no context",
|
|
||||||
run: func(t *testing.T, args runArgs) {
|
|
||||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
||||||
req.AddCookie(&http.Cookie{Name: "tinyauth-session", Value: "does-not-exist"})
|
|
||||||
userCtx, _ := args.do(req)
|
|
||||||
|
|
||||||
assert.Nil(t, userCtx)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Session for missing local user yields no context",
|
|
||||||
run: func(t *testing.T, args runArgs) {
|
|
||||||
uuid := "session-deleted-user"
|
|
||||||
seedSession(t, args.queries, repository.CreateSessionParams{
|
|
||||||
UUID: uuid,
|
|
||||||
Username: "ghostuser",
|
|
||||||
Provider: "local",
|
|
||||||
Expiry: time.Now().Add(10 * time.Second).Unix(),
|
|
||||||
CreatedAt: time.Now().Unix(),
|
|
||||||
})
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
||||||
req.AddCookie(&http.Cookie{Name: "tinyauth-session", Value: uuid})
|
|
||||||
userCtx, _ := args.do(req)
|
|
||||||
|
|
||||||
assert.Nil(t, userCtx)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Expired session cookie yields no context",
|
|
||||||
run: func(t *testing.T, args runArgs) {
|
|
||||||
uuid := "session-expired"
|
|
||||||
seedSession(t, args.queries, repository.CreateSessionParams{
|
|
||||||
UUID: uuid,
|
|
||||||
Username: "testuser",
|
|
||||||
Provider: "local",
|
|
||||||
Expiry: time.Now().Add(-1 * time.Second).Unix(),
|
|
||||||
CreatedAt: time.Now().Add(-10 * time.Second).Unix(),
|
|
||||||
})
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
||||||
req.AddCookie(&http.Cookie{Name: "tinyauth-session", Value: uuid})
|
|
||||||
userCtx, _ := args.do(req)
|
|
||||||
|
|
||||||
assert.Nil(t, userCtx)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Valid basic auth sets authenticated local context",
|
|
||||||
run: func(t *testing.T, args runArgs) {
|
|
||||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
||||||
req.Header.Set("Authorization", basicAuthHeader("testuser", "password"))
|
|
||||||
userCtx, _ := args.do(req)
|
|
||||||
|
|
||||||
require.NotNil(t, userCtx)
|
|
||||||
assert.Equal(t, model.ProviderLocal, userCtx.Provider)
|
|
||||||
assert.Equal(t, "testuser", userCtx.GetUsername())
|
|
||||||
assert.True(t, userCtx.Authenticated)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Invalid basic auth password yields no context",
|
|
||||||
run: func(t *testing.T, args runArgs) {
|
|
||||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
||||||
req.Header.Set("Authorization", basicAuthHeader("testuser", "wrongpassword"))
|
|
||||||
userCtx, _ := args.do(req)
|
|
||||||
|
|
||||||
assert.Nil(t, userCtx)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Basic auth is rejected for users with totp",
|
|
||||||
run: func(t *testing.T, args runArgs) {
|
|
||||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
||||||
req.Header.Set("Authorization", basicAuthHeader("totpuser", "password"))
|
|
||||||
userCtx, _ := args.do(req)
|
|
||||||
|
|
||||||
assert.Nil(t, userCtx)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Locked account on basic auth sets lock headers",
|
|
||||||
run: func(t *testing.T, args runArgs) {
|
|
||||||
for range 3 {
|
|
||||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
||||||
req.Header.Set("Authorization", basicAuthHeader("testuser", "wrongpassword"))
|
|
||||||
args.do(req)
|
|
||||||
}
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
||||||
req.Header.Set("Authorization", basicAuthHeader("testuser", "password"))
|
|
||||||
userCtx, recorder := args.do(req)
|
|
||||||
|
|
||||||
assert.Nil(t, userCtx)
|
|
||||||
assert.Equal(t, "true", recorder.Header().Get("x-tinyauth-lock-locked"))
|
|
||||||
assert.NotEmpty(t, recorder.Header().Get("x-tinyauth-lock-reset"))
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Cookie auth takes precedence over basic auth",
|
|
||||||
run: func(t *testing.T, args runArgs) {
|
|
||||||
uuid := "session-precedence"
|
|
||||||
seedSession(t, args.queries, repository.CreateSessionParams{
|
|
||||||
UUID: uuid,
|
|
||||||
Username: "testuser",
|
|
||||||
Provider: "local",
|
|
||||||
Expiry: time.Now().Add(10 * time.Second).Unix(),
|
|
||||||
CreatedAt: time.Now().Unix(),
|
|
||||||
})
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
||||||
req.AddCookie(&http.Cookie{Name: "tinyauth-session", Value: uuid})
|
|
||||||
req.Header.Set("Authorization", basicAuthHeader("totpuser", "password"))
|
|
||||||
userCtx, _ := args.do(req)
|
|
||||||
|
|
||||||
require.NotNil(t, userCtx)
|
|
||||||
assert.Equal(t, "testuser", userCtx.GetUsername())
|
|
||||||
assert.True(t, userCtx.Authenticated)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Ensure fallback to basic auth when cookie is missing",
|
|
||||||
run: func(t *testing.T, args runArgs) {
|
|
||||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
||||||
req.Header.Set("Authorization", basicAuthHeader("testuser", "password"))
|
|
||||||
userCtx, _ := args.do(req)
|
|
||||||
|
|
||||||
require.NotNil(t, userCtx)
|
|
||||||
assert.Equal(t, "testuser", userCtx.GetUsername())
|
|
||||||
assert.True(t, userCtx.Authenticated)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.TODO()
|
|
||||||
wg := &sync.WaitGroup{}
|
|
||||||
|
|
||||||
app := bootstrap.NewBootstrapApp(cfg)
|
|
||||||
|
|
||||||
err := app.SetupDatabase()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
queries := repository.New(app.GetDB())
|
|
||||||
|
|
||||||
broker := service.NewOAuthBrokerService(log, map[string]model.OAuthServiceConfig{}, ctx)
|
|
||||||
authService := service.NewAuthService(log, cfg, runtime, ctx, wg, nil, queries, broker)
|
|
||||||
|
|
||||||
contextMiddleware := middleware.NewContextMiddleware(log, runtime, authService, broker)
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
authService.ClearRateLimitsTestingOnly()
|
|
||||||
t.Run(test.description, func(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
|
|
||||||
do := func(req *http.Request) (*model.UserContext, *httptest.ResponseRecorder) {
|
|
||||||
var captured *model.UserContext
|
|
||||||
router := gin.New()
|
|
||||||
router.Use(contextMiddleware.Middleware())
|
|
||||||
handler := func(c *gin.Context) {
|
|
||||||
if val, exists := c.Get("context"); exists {
|
|
||||||
captured, _ = val.(*model.UserContext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
router.GET("/api/test", handler)
|
|
||||||
router.GET("/api/healthz", handler)
|
|
||||||
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(recorder, req)
|
|
||||||
return captured, recorder
|
|
||||||
}
|
|
||||||
|
|
||||||
test.run(t, runArgs{do: do, queries: queries})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
app.GetDB().Close()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
package middleware_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Note: This code is duplicated from controller_test.go
|
|
||||||
|
|
||||||
var testingTOTPSecret = "JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK"
|
|
||||||
|
|
||||||
func createTestConfigs(t *testing.T) (model.Config, model.RuntimeConfig) {
|
|
||||||
tempDir := t.TempDir()
|
|
||||||
|
|
||||||
config := model.Config{
|
|
||||||
UI: model.UIConfig{
|
|
||||||
Title: "Tinyauth Test",
|
|
||||||
ForgotPasswordMessage: "foo",
|
|
||||||
BackgroundImage: "/background.jpg",
|
|
||||||
WarningsEnabled: true,
|
|
||||||
},
|
|
||||||
OAuth: model.OAuthConfig{
|
|
||||||
AutoRedirect: "none",
|
|
||||||
},
|
|
||||||
OIDC: model.OIDCConfig{
|
|
||||||
Clients: map[string]model.OIDCClientConfig{
|
|
||||||
"test": {
|
|
||||||
ClientID: "some-client-id",
|
|
||||||
ClientSecret: "some-client-secret",
|
|
||||||
TrustedRedirectURIs: []string{"https://test.example.com/callback"},
|
|
||||||
Name: "Test Client",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
PrivateKeyPath: path.Join(tempDir, "key.pem"),
|
|
||||||
PublicKeyPath: path.Join(tempDir, "key.pub"),
|
|
||||||
},
|
|
||||||
Auth: model.AuthConfig{
|
|
||||||
SessionExpiry: 10,
|
|
||||||
LoginTimeout: 10,
|
|
||||||
LoginMaxRetries: 3,
|
|
||||||
},
|
|
||||||
Database: model.DatabaseConfig{
|
|
||||||
Path: path.Join(tempDir, "test.db"),
|
|
||||||
},
|
|
||||||
Resources: model.ResourcesConfig{
|
|
||||||
Enabled: true,
|
|
||||||
Path: path.Join(tempDir, "resources"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
passwd, err := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
runtime := model.RuntimeConfig{
|
|
||||||
ConfiguredProviders: []model.Provider{
|
|
||||||
{
|
|
||||||
Name: "Local",
|
|
||||||
ID: "local",
|
|
||||||
OAuth: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
LocalUsers: []model.LocalUser{
|
|
||||||
{
|
|
||||||
Username: "testuser",
|
|
||||||
Password: string(passwd),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Username: "totpuser",
|
|
||||||
Password: string(passwd),
|
|
||||||
TOTPSecret: testingTOTPSecret,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Username: "attruser",
|
|
||||||
Password: string(passwd),
|
|
||||||
Attributes: model.UserAttributes{
|
|
||||||
Name: "Alice Smith",
|
|
||||||
Email: "alice@example.com",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Username: "attrtotpuser",
|
|
||||||
Password: string(passwd),
|
|
||||||
TOTPSecret: testingTOTPSecret,
|
|
||||||
Attributes: model.UserAttributes{
|
|
||||||
Name: "Bob Jones",
|
|
||||||
Email: "bob@example.com",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
CookieDomain: "example.com",
|
|
||||||
AppURL: "https://tinyauth.example.com",
|
|
||||||
SessionCookieName: "tinyauth-session",
|
|
||||||
OIDCClients: func() []model.OIDCClientConfig {
|
|
||||||
var clients []model.OIDCClientConfig
|
|
||||||
for id, client := range config.OIDC.Clients {
|
|
||||||
client.ID = id
|
|
||||||
clients = append(clients, client)
|
|
||||||
}
|
|
||||||
return clients
|
|
||||||
}(),
|
|
||||||
}
|
|
||||||
|
|
||||||
return config, runtime
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/assets"
|
"github.com/steveiliop56/tinyauth/internal/assets"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -18,25 +19,29 @@ type UIMiddleware struct {
|
|||||||
uiFileServer http.Handler
|
uiFileServer http.Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUIMiddleware() (*UIMiddleware, error) {
|
func NewUIMiddleware() *UIMiddleware {
|
||||||
m := &UIMiddleware{}
|
return &UIMiddleware{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UIMiddleware) Init() error {
|
||||||
ui, err := fs.Sub(assets.FrontendAssets, "dist")
|
ui, err := fs.Sub(assets.FrontendAssets, "dist")
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to load ui assets: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
m.uiFs = ui
|
m.uiFs = ui
|
||||||
m.uiFileServer = http.FileServerFS(ui)
|
m.uiFileServer = http.FileServerFS(ui)
|
||||||
|
|
||||||
return m, nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *UIMiddleware) Middleware() gin.HandlerFunc {
|
func (m *UIMiddleware) Middleware() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
path := strings.TrimPrefix(c.Request.URL.Path, "/")
|
path := strings.TrimPrefix(c.Request.URL.Path, "/")
|
||||||
|
|
||||||
|
tlog.App.Debug().Str("path", path).Msg("path")
|
||||||
|
|
||||||
switch strings.SplitN(path, "/", 2)[0] {
|
switch strings.SplitN(path, "/", 2)[0] {
|
||||||
case "api", "resources", ".well-known":
|
case "api", "resources", ".well-known":
|
||||||
c.Next()
|
c.Next()
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// See context middleware for explanation of why we have to do this
|
// See context middleware for explanation of why we have to do this
|
||||||
@@ -17,14 +17,14 @@ var (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
type ZerologMiddleware struct {
|
type ZerologMiddleware struct{}
|
||||||
log *logger.Logger
|
|
||||||
|
func NewZerologMiddleware() *ZerologMiddleware {
|
||||||
|
return &ZerologMiddleware{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewZerologMiddleware(log *logger.Logger) *ZerologMiddleware {
|
func (m *ZerologMiddleware) Init() error {
|
||||||
return &ZerologMiddleware{
|
return nil
|
||||||
log: log,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ZerologMiddleware) logPath(path string) bool {
|
func (m *ZerologMiddleware) logPath(path string) bool {
|
||||||
@@ -50,7 +50,7 @@ func (m *ZerologMiddleware) Middleware() gin.HandlerFunc {
|
|||||||
|
|
||||||
latency := time.Since(tStart).String()
|
latency := time.Since(tStart).String()
|
||||||
|
|
||||||
subLogger := m.log.HTTP.With().Str("method", method).
|
subLogger := tlog.HTTP.With().Str("method", method).
|
||||||
Str("path", path).
|
Str("path", path).
|
||||||
Str("address", address).
|
Str("address", address).
|
||||||
Str("client_ip", clientIP).
|
Str("client_ip", clientIP).
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
const DefaultNamePrefix = "TINYAUTH_"
|
|
||||||
|
|
||||||
const APIServer = "https://api.tinyauth.app"
|
|
||||||
|
|
||||||
type Claims struct {
|
|
||||||
Sub string `json:"sub"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
PreferredUsername string `json:"preferred_username"`
|
|
||||||
Groups any `json:"groups"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var OverrideProviders = map[string]string{
|
|
||||||
"google": "Google",
|
|
||||||
"github": "GitHub",
|
|
||||||
}
|
|
||||||
|
|
||||||
const SessionCookieName = "tinyauth-session"
|
|
||||||
const CSRFCookieName = "tinyauth-csrf"
|
|
||||||
const RedirectCookieName = "tinyauth-redirect"
|
|
||||||
const OAuthSessionCookieName = "tinyauth-oauth"
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrUserContextNotFound = errors.New("user context not found")
|
|
||||||
)
|
|
||||||
|
|
||||||
type ProviderType int
|
|
||||||
|
|
||||||
const (
|
|
||||||
ProviderLocal ProviderType = iota
|
|
||||||
ProviderBasicAuth
|
|
||||||
ProviderOAuth
|
|
||||||
ProviderLDAP
|
|
||||||
)
|
|
||||||
|
|
||||||
type UserContext struct {
|
|
||||||
Authenticated bool
|
|
||||||
Provider ProviderType
|
|
||||||
Local *LocalContext
|
|
||||||
OAuth *OAuthContext
|
|
||||||
LDAP *LDAPContext
|
|
||||||
}
|
|
||||||
|
|
||||||
type BaseContext struct {
|
|
||||||
Username string
|
|
||||||
Name string
|
|
||||||
Email string
|
|
||||||
}
|
|
||||||
|
|
||||||
type LocalContext struct {
|
|
||||||
BaseContext
|
|
||||||
TOTPPending bool
|
|
||||||
Attributes UserAttributes
|
|
||||||
}
|
|
||||||
|
|
||||||
type OAuthContext struct {
|
|
||||||
BaseContext
|
|
||||||
Groups []string
|
|
||||||
Sub string
|
|
||||||
DisplayName string
|
|
||||||
ID string
|
|
||||||
}
|
|
||||||
|
|
||||||
type LDAPContext struct {
|
|
||||||
BaseContext
|
|
||||||
Groups []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *UserContext) IsAuthenticated() bool {
|
|
||||||
return c.Authenticated
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *UserContext) IsLocal() bool {
|
|
||||||
return c.Provider == ProviderLocal && c.Local != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *UserContext) IsOAuth() bool {
|
|
||||||
return c.Provider == ProviderOAuth && c.OAuth != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *UserContext) IsLDAP() bool {
|
|
||||||
return c.Provider == ProviderLDAP && c.LDAP != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *UserContext) IsBasicAuth() bool {
|
|
||||||
return c.Provider == ProviderBasicAuth && c.Local != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *UserContext) NewFromGin(ginctx *gin.Context) (*UserContext, error) {
|
|
||||||
userContextValue, exists := ginctx.Get("context")
|
|
||||||
|
|
||||||
if !exists {
|
|
||||||
return nil, ErrUserContextNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
userContext, ok := userContextValue.(*UserContext)
|
|
||||||
|
|
||||||
if !ok || userContext == nil {
|
|
||||||
return nil, errors.New("invalid user context type")
|
|
||||||
}
|
|
||||||
|
|
||||||
if userContext.LDAP == nil && userContext.Local == nil && userContext.OAuth == nil {
|
|
||||||
return nil, errors.New("incomplete user context")
|
|
||||||
}
|
|
||||||
|
|
||||||
*c = *userContext
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compatability layer until we get an excuse to drop in database migrations
|
|
||||||
func (c *UserContext) NewFromSession(session *repository.Session) (*UserContext, error) {
|
|
||||||
*c = UserContext{
|
|
||||||
Authenticated: !session.TotpPending,
|
|
||||||
}
|
|
||||||
|
|
||||||
switch session.Provider {
|
|
||||||
case "local":
|
|
||||||
c.Provider = ProviderLocal
|
|
||||||
c.Local = &LocalContext{
|
|
||||||
BaseContext: BaseContext{
|
|
||||||
Username: session.Username,
|
|
||||||
Name: session.Name,
|
|
||||||
Email: session.Email,
|
|
||||||
},
|
|
||||||
TOTPPending: session.TotpPending,
|
|
||||||
}
|
|
||||||
case "ldap":
|
|
||||||
c.Provider = ProviderLDAP
|
|
||||||
c.LDAP = &LDAPContext{
|
|
||||||
BaseContext: BaseContext{
|
|
||||||
Username: session.Username,
|
|
||||||
Name: session.Name,
|
|
||||||
Email: session.Email,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
// By default we assume an unknown name which is oauth
|
|
||||||
default:
|
|
||||||
c.Provider = ProviderOAuth
|
|
||||||
c.OAuth = &OAuthContext{
|
|
||||||
BaseContext: BaseContext{
|
|
||||||
Username: session.Username,
|
|
||||||
Name: session.Name,
|
|
||||||
Email: session.Email,
|
|
||||||
},
|
|
||||||
Groups: func() []string {
|
|
||||||
if session.OAuthGroups == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return strings.Split(session.OAuthGroups, ",")
|
|
||||||
}(),
|
|
||||||
Sub: session.OAuthSub,
|
|
||||||
DisplayName: session.OAuthName,
|
|
||||||
ID: session.Provider,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *UserContext) GetUsername() string {
|
|
||||||
switch c.Provider {
|
|
||||||
case ProviderLocal:
|
|
||||||
if c.Local == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return c.Local.Username
|
|
||||||
case ProviderLDAP:
|
|
||||||
if c.LDAP == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return c.LDAP.Username
|
|
||||||
case ProviderBasicAuth:
|
|
||||||
if c.Local == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return c.Local.Username
|
|
||||||
case ProviderOAuth:
|
|
||||||
if c.OAuth == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return c.OAuth.Username
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *UserContext) GetEmail() string {
|
|
||||||
switch c.Provider {
|
|
||||||
case ProviderLocal:
|
|
||||||
if c.Local == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return c.Local.Email
|
|
||||||
case ProviderLDAP:
|
|
||||||
if c.LDAP == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return c.LDAP.Email
|
|
||||||
case ProviderBasicAuth:
|
|
||||||
if c.Local == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return c.Local.Email
|
|
||||||
case ProviderOAuth:
|
|
||||||
if c.OAuth == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return c.OAuth.Email
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *UserContext) GetName() string {
|
|
||||||
switch c.Provider {
|
|
||||||
case ProviderLocal:
|
|
||||||
if c.Local == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return c.Local.Name
|
|
||||||
case ProviderLDAP:
|
|
||||||
if c.LDAP == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return c.LDAP.Name
|
|
||||||
case ProviderBasicAuth:
|
|
||||||
if c.Local == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return c.Local.Name
|
|
||||||
case ProviderOAuth:
|
|
||||||
if c.OAuth == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return c.OAuth.Name
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *UserContext) GetProviderID() string {
|
|
||||||
switch c.Provider {
|
|
||||||
case ProviderBasicAuth, ProviderLocal:
|
|
||||||
return "local"
|
|
||||||
case ProviderLDAP:
|
|
||||||
return "ldap"
|
|
||||||
case ProviderOAuth:
|
|
||||||
return c.OAuth.ID
|
|
||||||
default:
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *UserContext) TOTPPending() bool {
|
|
||||||
if c.Provider == ProviderLocal && c.Local != nil {
|
|
||||||
return c.Local.TOTPPending
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *UserContext) OAuthName() string {
|
|
||||||
if c.Provider == ProviderOAuth && c.OAuth != nil {
|
|
||||||
return c.OAuth.DisplayName
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
package model_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestContext(t *testing.T) {
|
|
||||||
newGinCtx := func(value any, set bool) *gin.Context {
|
|
||||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
|
||||||
if set {
|
|
||||||
c.Set("context", value)
|
|
||||||
}
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
description string
|
|
||||||
context *model.UserContext
|
|
||||||
run func(*testing.T, *model.UserContext) any
|
|
||||||
expected any
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
description: "IsAuthenticated reflects Authenticated field",
|
|
||||||
context: &model.UserContext{Authenticated: true},
|
|
||||||
run: func(t *testing.T, c *model.UserContext) any { return c.IsAuthenticated() },
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "IsLocal returns true for ProviderLocal",
|
|
||||||
context: &model.UserContext{Provider: model.ProviderLocal, Local: &model.LocalContext{}},
|
|
||||||
run: func(t *testing.T, c *model.UserContext) any { return c.IsLocal() },
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "IsOAuth returns true for ProviderOAuth",
|
|
||||||
context: &model.UserContext{Provider: model.ProviderOAuth, OAuth: &model.OAuthContext{}},
|
|
||||||
run: func(t *testing.T, c *model.UserContext) any { return c.IsOAuth() },
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "IsLDAP returns true for ProviderLDAP",
|
|
||||||
context: &model.UserContext{Provider: model.ProviderLDAP, LDAP: &model.LDAPContext{}},
|
|
||||||
run: func(t *testing.T, c *model.UserContext) any { return c.IsLDAP() },
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "IsBasicAuth returns true for ProviderBasicAuth",
|
|
||||||
context: &model.UserContext{Provider: model.ProviderBasicAuth, Local: &model.LocalContext{}},
|
|
||||||
run: func(t *testing.T, c *model.UserContext) any { return c.IsBasicAuth() },
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "NewFromSession local session is authenticated and ProviderLocal",
|
|
||||||
context: &model.UserContext{},
|
|
||||||
run: func(t *testing.T, c *model.UserContext) any {
|
|
||||||
got, err := c.NewFromSession(&repository.Session{
|
|
||||||
Username: "alice", Email: "alice@example.com", Name: "Alice",
|
|
||||||
Provider: "local",
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
return [2]any{got.Provider, got.Authenticated}
|
|
||||||
},
|
|
||||||
expected: [2]any{model.ProviderLocal, true},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "NewFromSession local session with TotpPending is not authenticated",
|
|
||||||
context: &model.UserContext{},
|
|
||||||
run: func(t *testing.T, c *model.UserContext) any {
|
|
||||||
got, err := c.NewFromSession(&repository.Session{
|
|
||||||
Username: "bob", Provider: "local", TotpPending: true,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
return got.Authenticated
|
|
||||||
},
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "NewFromSession ldap session is ProviderLDAP",
|
|
||||||
context: &model.UserContext{},
|
|
||||||
run: func(t *testing.T, c *model.UserContext) any {
|
|
||||||
got, err := c.NewFromSession(&repository.Session{
|
|
||||||
Username: "carol", Provider: "ldap",
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
return got.Provider
|
|
||||||
},
|
|
||||||
expected: model.ProviderLDAP,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "NewFromSession unknown provider defaults to OAuth and populates oauth fields",
|
|
||||||
context: &model.UserContext{},
|
|
||||||
run: func(t *testing.T, c *model.UserContext) any {
|
|
||||||
got, err := c.NewFromSession(&repository.Session{
|
|
||||||
Username: "dave", Provider: "github",
|
|
||||||
OAuthGroups: "devs,admins", OAuthSub: "sub-123", OAuthName: "GitHub",
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
return [5]any{got.Provider, got.OAuth.ID, got.OAuth.Sub, got.OAuth.DisplayName, got.OAuth.Groups}
|
|
||||||
},
|
|
||||||
expected: [5]any{model.ProviderOAuth, "github", "sub-123", "GitHub", []string{"devs", "admins"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Local getters return BaseContext fields",
|
|
||||||
context: &model.UserContext{
|
|
||||||
Provider: model.ProviderLocal,
|
|
||||||
Local: &model.LocalContext{BaseContext: model.BaseContext{Username: "alice", Email: "alice@example.com", Name: "Alice"}},
|
|
||||||
},
|
|
||||||
run: func(t *testing.T, c *model.UserContext) any {
|
|
||||||
return [3]string{c.GetUsername(), c.GetEmail(), c.GetName()}
|
|
||||||
},
|
|
||||||
expected: [3]string{"alice", "alice@example.com", "Alice"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "BasicAuth getters fall back to local fields",
|
|
||||||
context: &model.UserContext{
|
|
||||||
Provider: model.ProviderBasicAuth,
|
|
||||||
Local: &model.LocalContext{BaseContext: model.BaseContext{Username: "bob", Email: "bob@example.com", Name: "Bob"}},
|
|
||||||
},
|
|
||||||
run: func(t *testing.T, c *model.UserContext) any {
|
|
||||||
return [3]string{c.GetUsername(), c.GetEmail(), c.GetName()}
|
|
||||||
},
|
|
||||||
expected: [3]string{"bob", "bob@example.com", "Bob"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "LDAP getters return LDAP fields",
|
|
||||||
context: &model.UserContext{
|
|
||||||
Provider: model.ProviderLDAP,
|
|
||||||
LDAP: &model.LDAPContext{BaseContext: model.BaseContext{Username: "carol", Email: "carol@example.com", Name: "Carol"}},
|
|
||||||
},
|
|
||||||
run: func(t *testing.T, c *model.UserContext) any {
|
|
||||||
return [3]string{c.GetUsername(), c.GetEmail(), c.GetName()}
|
|
||||||
},
|
|
||||||
expected: [3]string{"carol", "carol@example.com", "Carol"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "OAuth getters return OAuth fields",
|
|
||||||
context: &model.UserContext{
|
|
||||||
Provider: model.ProviderOAuth,
|
|
||||||
OAuth: &model.OAuthContext{BaseContext: model.BaseContext{Username: "dave", Email: "dave@example.com", Name: "Dave"}},
|
|
||||||
},
|
|
||||||
run: func(t *testing.T, c *model.UserContext) any {
|
|
||||||
return [3]string{c.GetUsername(), c.GetEmail(), c.GetName()}
|
|
||||||
},
|
|
||||||
expected: [3]string{"dave", "dave@example.com", "Dave"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "ProviderName returns 'local' for ProviderLocal",
|
|
||||||
context: &model.UserContext{Provider: model.ProviderLocal},
|
|
||||||
run: func(t *testing.T, c *model.UserContext) any { return c.GetProviderID() },
|
|
||||||
expected: "local",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "ProviderName returns 'local' for ProviderBasicAuth",
|
|
||||||
context: &model.UserContext{Provider: model.ProviderBasicAuth},
|
|
||||||
run: func(t *testing.T, c *model.UserContext) any { return c.GetProviderID() },
|
|
||||||
expected: "local",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "ProviderName returns 'ldap' for ProviderLDAP",
|
|
||||||
context: &model.UserContext{Provider: model.ProviderLDAP},
|
|
||||||
run: func(t *testing.T, c *model.UserContext) any { return c.GetProviderID() },
|
|
||||||
expected: "ldap",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "ProviderName returns OAuth provider ID for ProviderOAuth",
|
|
||||||
context: &model.UserContext{
|
|
||||||
Provider: model.ProviderOAuth,
|
|
||||||
OAuth: &model.OAuthContext{ID: "github"},
|
|
||||||
},
|
|
||||||
run: func(t *testing.T, c *model.UserContext) any { return c.GetProviderID() },
|
|
||||||
expected: "github",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "TOTPPending returns true when local context is pending",
|
|
||||||
context: &model.UserContext{
|
|
||||||
Provider: model.ProviderLocal,
|
|
||||||
Local: &model.LocalContext{TOTPPending: true},
|
|
||||||
},
|
|
||||||
run: func(t *testing.T, c *model.UserContext) any { return c.TOTPPending() },
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "TOTPPending returns false when local context is not pending",
|
|
||||||
context: &model.UserContext{
|
|
||||||
Provider: model.ProviderLocal,
|
|
||||||
Local: &model.LocalContext{TOTPPending: false},
|
|
||||||
},
|
|
||||||
run: func(t *testing.T, c *model.UserContext) any { return c.TOTPPending() },
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "TOTPPending returns false for non-local providers",
|
|
||||||
context: &model.UserContext{Provider: model.ProviderOAuth, OAuth: &model.OAuthContext{}},
|
|
||||||
run: func(t *testing.T, c *model.UserContext) any { return c.TOTPPending() },
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "OAuthName returns DisplayName for ProviderOAuth",
|
|
||||||
context: &model.UserContext{
|
|
||||||
Provider: model.ProviderOAuth,
|
|
||||||
OAuth: &model.OAuthContext{DisplayName: "Google"},
|
|
||||||
},
|
|
||||||
run: func(t *testing.T, c *model.UserContext) any { return c.OAuthName() },
|
|
||||||
expected: "Google",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "OAuthName returns empty string for non-oauth providers",
|
|
||||||
context: &model.UserContext{Provider: model.ProviderLocal, Local: &model.LocalContext{}},
|
|
||||||
run: func(t *testing.T, c *model.UserContext) any { return c.OAuthName() },
|
|
||||||
expected: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "NewFromGin populates context from gin value",
|
|
||||||
context: &model.UserContext{},
|
|
||||||
run: func(t *testing.T, c *model.UserContext) any {
|
|
||||||
stored := &model.UserContext{
|
|
||||||
Authenticated: true,
|
|
||||||
Provider: model.ProviderLocal,
|
|
||||||
Local: &model.LocalContext{BaseContext: model.BaseContext{Username: "alice"}},
|
|
||||||
}
|
|
||||||
got, err := c.NewFromGin(newGinCtx(stored, true))
|
|
||||||
require.NoError(t, err)
|
|
||||||
return [2]any{got.Authenticated, got.GetUsername()}
|
|
||||||
},
|
|
||||||
expected: [2]any{true, "alice"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "NewFromGin returns error when context value is missing",
|
|
||||||
context: &model.UserContext{},
|
|
||||||
run: func(t *testing.T, c *model.UserContext) any {
|
|
||||||
_, err := c.NewFromGin(newGinCtx(nil, false))
|
|
||||||
return err.Error()
|
|
||||||
},
|
|
||||||
expected: model.ErrUserContextNotFound.Error(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "NewFromGin returns error when context value has wrong type",
|
|
||||||
context: &model.UserContext{},
|
|
||||||
run: func(t *testing.T, c *model.UserContext) any {
|
|
||||||
_, err := c.NewFromGin(newGinCtx("not a user context", true))
|
|
||||||
return err.Error()
|
|
||||||
},
|
|
||||||
expected: "invalid user context type",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "NewFromGin returns an error when context doesn't include user information",
|
|
||||||
context: &model.UserContext{},
|
|
||||||
run: func(t *testing.T, c *model.UserContext) any {
|
|
||||||
_, err := c.NewFromGin(newGinCtx(&model.UserContext{Provider: model.ProviderLocal}, true))
|
|
||||||
return err.Error()
|
|
||||||
},
|
|
||||||
expected: "incomplete user context",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Getters should not panic if provider context is empty",
|
|
||||||
context: &model.UserContext{Provider: model.ProviderLocal},
|
|
||||||
run: func(t *testing.T, c *model.UserContext) any {
|
|
||||||
return [3]string{c.GetUsername(), c.GetEmail(), c.GetName()}
|
|
||||||
},
|
|
||||||
expected: [3]string{"", "", ""},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.description, func(t *testing.T) {
|
|
||||||
assert.Equal(t, test.expected, test.run(t, test.context))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
type RuntimeConfig struct {
|
|
||||||
AppURL string
|
|
||||||
UUID string
|
|
||||||
CookieDomain string
|
|
||||||
SessionCookieName string
|
|
||||||
CSRFCookieName string
|
|
||||||
RedirectCookieName string
|
|
||||||
OAuthSessionCookieName string
|
|
||||||
LocalUsers []LocalUser
|
|
||||||
OAuthProviders map[string]OAuthServiceConfig
|
|
||||||
OAuthWhitelist []string
|
|
||||||
ConfiguredProviders []Provider
|
|
||||||
OIDCClients []OIDCClientConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
type Provider struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
ID string `json:"id"`
|
|
||||||
OAuth bool `json:"oauth"`
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
type UserSearchType int
|
|
||||||
|
|
||||||
const (
|
|
||||||
UserLocal UserSearchType = iota
|
|
||||||
UserLDAP
|
|
||||||
)
|
|
||||||
|
|
||||||
type LDAPUser struct {
|
|
||||||
DN string
|
|
||||||
Groups []string
|
|
||||||
}
|
|
||||||
|
|
||||||
type LocalUser struct {
|
|
||||||
Username string
|
|
||||||
Password string
|
|
||||||
TOTPSecret string
|
|
||||||
Attributes UserAttributes
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserSearch struct {
|
|
||||||
Username string
|
|
||||||
Type UserSearchType
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
var Version = "development"
|
|
||||||
var CommitHash = "development"
|
|
||||||
var BuildTimestamp = "0000-00-00T00:00:00Z"
|
|
||||||
@@ -34,19 +34,6 @@ type OidcUserinfo struct {
|
|||||||
Email string
|
Email string
|
||||||
Groups string
|
Groups string
|
||||||
UpdatedAt int64
|
UpdatedAt int64
|
||||||
GivenName string
|
|
||||||
FamilyName string
|
|
||||||
MiddleName string
|
|
||||||
Nickname string
|
|
||||||
Profile string
|
|
||||||
Picture string
|
|
||||||
Website string
|
|
||||||
Gender string
|
|
||||||
Birthdate string
|
|
||||||
Zoneinfo string
|
|
||||||
Locale string
|
|
||||||
PhoneNumber string
|
|
||||||
Address string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Session struct {
|
type Session struct {
|
||||||
|
|||||||
@@ -124,24 +124,11 @@ INSERT INTO "oidc_userinfo" (
|
|||||||
"preferred_username",
|
"preferred_username",
|
||||||
"email",
|
"email",
|
||||||
"groups",
|
"groups",
|
||||||
"updated_at",
|
"updated_at"
|
||||||
"given_name",
|
|
||||||
"family_name",
|
|
||||||
"middle_name",
|
|
||||||
"nickname",
|
|
||||||
"profile",
|
|
||||||
"picture",
|
|
||||||
"website",
|
|
||||||
"gender",
|
|
||||||
"birthdate",
|
|
||||||
"zoneinfo",
|
|
||||||
"locale",
|
|
||||||
"phone_number",
|
|
||||||
"address"
|
|
||||||
) VALUES (
|
) VALUES (
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
?, ?, ?, ?, ?, ?
|
||||||
)
|
)
|
||||||
RETURNING sub, name, preferred_username, email, "groups", updated_at, given_name, family_name, middle_name, nickname, profile, picture, website, gender, birthdate, zoneinfo, locale, phone_number, address
|
RETURNING sub, name, preferred_username, email, "groups", updated_at
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateOidcUserInfoParams struct {
|
type CreateOidcUserInfoParams struct {
|
||||||
@@ -151,19 +138,6 @@ type CreateOidcUserInfoParams struct {
|
|||||||
Email string
|
Email string
|
||||||
Groups string
|
Groups string
|
||||||
UpdatedAt int64
|
UpdatedAt int64
|
||||||
GivenName string
|
|
||||||
FamilyName string
|
|
||||||
MiddleName string
|
|
||||||
Nickname string
|
|
||||||
Profile string
|
|
||||||
Picture string
|
|
||||||
Website string
|
|
||||||
Gender string
|
|
||||||
Birthdate string
|
|
||||||
Zoneinfo string
|
|
||||||
Locale string
|
|
||||||
PhoneNumber string
|
|
||||||
Address string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) CreateOidcUserInfo(ctx context.Context, arg CreateOidcUserInfoParams) (OidcUserinfo, error) {
|
func (q *Queries) CreateOidcUserInfo(ctx context.Context, arg CreateOidcUserInfoParams) (OidcUserinfo, error) {
|
||||||
@@ -174,19 +148,6 @@ func (q *Queries) CreateOidcUserInfo(ctx context.Context, arg CreateOidcUserInfo
|
|||||||
arg.Email,
|
arg.Email,
|
||||||
arg.Groups,
|
arg.Groups,
|
||||||
arg.UpdatedAt,
|
arg.UpdatedAt,
|
||||||
arg.GivenName,
|
|
||||||
arg.FamilyName,
|
|
||||||
arg.MiddleName,
|
|
||||||
arg.Nickname,
|
|
||||||
arg.Profile,
|
|
||||||
arg.Picture,
|
|
||||||
arg.Website,
|
|
||||||
arg.Gender,
|
|
||||||
arg.Birthdate,
|
|
||||||
arg.Zoneinfo,
|
|
||||||
arg.Locale,
|
|
||||||
arg.PhoneNumber,
|
|
||||||
arg.Address,
|
|
||||||
)
|
)
|
||||||
var i OidcUserinfo
|
var i OidcUserinfo
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -196,19 +157,6 @@ func (q *Queries) CreateOidcUserInfo(ctx context.Context, arg CreateOidcUserInfo
|
|||||||
&i.Email,
|
&i.Email,
|
||||||
&i.Groups,
|
&i.Groups,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.GivenName,
|
|
||||||
&i.FamilyName,
|
|
||||||
&i.MiddleName,
|
|
||||||
&i.Nickname,
|
|
||||||
&i.Profile,
|
|
||||||
&i.Picture,
|
|
||||||
&i.Website,
|
|
||||||
&i.Gender,
|
|
||||||
&i.Birthdate,
|
|
||||||
&i.Zoneinfo,
|
|
||||||
&i.Locale,
|
|
||||||
&i.PhoneNumber,
|
|
||||||
&i.Address,
|
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -508,7 +456,7 @@ func (q *Queries) GetOidcTokenBySub(ctx context.Context, sub string) (OidcToken,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getOidcUserInfo = `-- name: GetOidcUserInfo :one
|
const getOidcUserInfo = `-- name: GetOidcUserInfo :one
|
||||||
SELECT sub, name, preferred_username, email, "groups", updated_at, given_name, family_name, middle_name, nickname, profile, picture, website, gender, birthdate, zoneinfo, locale, phone_number, address FROM "oidc_userinfo"
|
SELECT sub, name, preferred_username, email, "groups", updated_at FROM "oidc_userinfo"
|
||||||
WHERE "sub" = ?
|
WHERE "sub" = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -522,19 +470,6 @@ func (q *Queries) GetOidcUserInfo(ctx context.Context, sub string) (OidcUserinfo
|
|||||||
&i.Email,
|
&i.Email,
|
||||||
&i.Groups,
|
&i.Groups,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.GivenName,
|
|
||||||
&i.FamilyName,
|
|
||||||
&i.MiddleName,
|
|
||||||
&i.Nickname,
|
|
||||||
&i.Profile,
|
|
||||||
&i.Picture,
|
|
||||||
&i.Website,
|
|
||||||
&i.Gender,
|
|
||||||
&i.Birthdate,
|
|
||||||
&i.Zoneinfo,
|
|
||||||
&i.Locale,
|
|
||||||
&i.PhoneNumber,
|
|
||||||
&i.Address,
|
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +1,54 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
"github.com/steveiliop56/tinyauth/internal/config"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
type LabelProvider interface {
|
|
||||||
GetLabels(appDomain string) (*model.App, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type AccessControlsService struct {
|
type AccessControlsService struct {
|
||||||
log *logger.Logger
|
docker *DockerService
|
||||||
labelProvider *LabelProvider
|
static map[string]config.App
|
||||||
static map[string]model.App
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAccessControlsService(
|
func NewAccessControlsService(docker *DockerService, static map[string]config.App) *AccessControlsService {
|
||||||
log *logger.Logger,
|
|
||||||
labelProvider *LabelProvider,
|
|
||||||
static map[string]model.App) *AccessControlsService {
|
|
||||||
return &AccessControlsService{
|
return &AccessControlsService{
|
||||||
log: log,
|
docker: docker,
|
||||||
labelProvider: labelProvider,
|
static: static,
|
||||||
static: static,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (acls *AccessControlsService) lookupStaticACLs(domain string) *model.App {
|
func (acls *AccessControlsService) Init() error {
|
||||||
var appAcls *model.App
|
return nil // No initialization needed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acls *AccessControlsService) lookupStaticACLs(domain string) (config.App, error) {
|
||||||
for app, config := range acls.static {
|
for app, config := range acls.static {
|
||||||
if config.Config.Domain == domain {
|
if config.Config.Domain == domain {
|
||||||
acls.log.App.Debug().Str("name", app).Msg("Found matching container by domain")
|
tlog.App.Debug().Str("name", app).Msg("Found matching container by domain")
|
||||||
appAcls = &config
|
return config, nil
|
||||||
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 {
|
||||||
acls.log.App.Debug().Str("name", app).Msg("Found matching container by app name")
|
tlog.App.Debug().Str("name", app).Msg("Found matching container by app name")
|
||||||
appAcls = &config
|
return config, nil
|
||||||
break // If we find a match by app name, we can stop searching
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return appAcls
|
return config.App{}, errors.New("no results")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (acls *AccessControlsService) GetAccessControls(domain string) (*model.App, error) {
|
func (acls *AccessControlsService) GetAccessControls(domain string) (config.App, error) {
|
||||||
// First check in the static config
|
// First check in the static config
|
||||||
app := acls.lookupStaticACLs(domain)
|
app, err := acls.lookupStaticACLs(domain)
|
||||||
|
|
||||||
if app != nil {
|
if err == nil {
|
||||||
acls.log.App.Debug().Msg("Using static ACLs for app")
|
tlog.App.Debug().Msg("Using ACls from static configuration")
|
||||||
return app, nil
|
return app, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have a label provider configured, try to get ACLs from it
|
// Fallback to Docker labels
|
||||||
if acls.labelProvider != nil {
|
tlog.App.Debug().Msg("Falling back to Docker labels for ACLs")
|
||||||
return (*acls.labelProvider).GetLabels(domain)
|
return acls.docker.GetLabels(domain)
|
||||||
}
|
|
||||||
|
|
||||||
// no labels
|
|
||||||
return nil, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
+231
-251
@@ -5,22 +5,20 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
"github.com/steveiliop56/tinyauth/internal/config"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
"github.com/steveiliop56/tinyauth/internal/repository"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
|
|
||||||
"slices"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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/exp/slices"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,10 +28,6 @@ const MaxOAuthPendingSessions = 256
|
|||||||
const OAuthCleanupCount = 16
|
const OAuthCleanupCount = 16
|
||||||
const MaxLoginAttemptRecords = 256
|
const MaxLoginAttemptRecords = 256
|
||||||
|
|
||||||
var (
|
|
||||||
ErrUserNotFound = errors.New("user not found")
|
|
||||||
)
|
|
||||||
|
|
||||||
// slightly modified version of the AuthorizeRequest from the OIDC service to basically accept all
|
// slightly modified version of the AuthorizeRequest from the OIDC service to basically accept all
|
||||||
// parameters and pass them to the authorize page if needed
|
// parameters and pass them to the authorize page if needed
|
||||||
type OAuthURLParams struct {
|
type OAuthURLParams struct {
|
||||||
@@ -72,42 +66,41 @@ type Lockdown struct {
|
|||||||
ActiveUntil time.Time
|
ActiveUntil time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AuthServiceConfig struct {
|
||||||
|
Users []config.User
|
||||||
|
OauthWhitelist []string
|
||||||
|
SessionExpiry int
|
||||||
|
SessionMaxLifetime int
|
||||||
|
SecureCookie bool
|
||||||
|
CookieDomain string
|
||||||
|
LoginTimeout int
|
||||||
|
LoginMaxRetries int
|
||||||
|
SessionCookieName string
|
||||||
|
IP config.IPConfig
|
||||||
|
LDAPGroupsCacheTTL int
|
||||||
|
}
|
||||||
|
|
||||||
type AuthService struct {
|
type AuthService struct {
|
||||||
log *logger.Logger
|
config AuthServiceConfig
|
||||||
config model.Config
|
docker *DockerService
|
||||||
runtime model.RuntimeConfig
|
|
||||||
context context.Context
|
|
||||||
|
|
||||||
ldap *LdapService
|
|
||||||
queries *repository.Queries
|
|
||||||
oauthBroker *OAuthBrokerService
|
|
||||||
|
|
||||||
loginAttempts map[string]*LoginAttempt
|
loginAttempts map[string]*LoginAttempt
|
||||||
ldapGroupsCache map[string]*LdapGroupsCache
|
ldapGroupsCache map[string]*LdapGroupsCache
|
||||||
oauthPendingSessions map[string]*OAuthPendingSession
|
oauthPendingSessions map[string]*OAuthPendingSession
|
||||||
oauthMutex sync.RWMutex
|
oauthMutex sync.RWMutex
|
||||||
loginMutex sync.RWMutex
|
loginMutex sync.RWMutex
|
||||||
ldapGroupsMutex sync.RWMutex
|
ldapGroupsMutex sync.RWMutex
|
||||||
|
ldap *LdapService
|
||||||
|
queries *repository.Queries
|
||||||
|
oauthBroker *OAuthBrokerService
|
||||||
lockdown *Lockdown
|
lockdown *Lockdown
|
||||||
lockdownCtx context.Context
|
lockdownCtx context.Context
|
||||||
lockdownCancelFunc context.CancelFunc
|
lockdownCancelFunc context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthService(
|
func NewAuthService(config AuthServiceConfig, docker *DockerService, ldap *LdapService, queries *repository.Queries, oauthBroker *OAuthBrokerService) *AuthService {
|
||||||
log *logger.Logger,
|
return &AuthService{
|
||||||
config model.Config,
|
|
||||||
runtime model.RuntimeConfig,
|
|
||||||
ctx context.Context,
|
|
||||||
wg *sync.WaitGroup,
|
|
||||||
ldap *LdapService,
|
|
||||||
queries *repository.Queries,
|
|
||||||
oauthBroker *OAuthBrokerService,
|
|
||||||
) *AuthService {
|
|
||||||
service := &AuthService{
|
|
||||||
log: log,
|
|
||||||
runtime: runtime,
|
|
||||||
context: ctx,
|
|
||||||
config: config,
|
config: config,
|
||||||
|
docker: docker,
|
||||||
loginAttempts: make(map[string]*LoginAttempt),
|
loginAttempts: make(map[string]*LoginAttempt),
|
||||||
ldapGroupsCache: make(map[string]*LdapGroupsCache),
|
ldapGroupsCache: make(map[string]*LdapGroupsCache),
|
||||||
oauthPendingSessions: make(map[string]*OAuthPendingSession),
|
oauthPendingSessions: make(map[string]*OAuthPendingSession),
|
||||||
@@ -115,79 +108,86 @@ func NewAuthService(
|
|||||||
queries: queries,
|
queries: queries,
|
||||||
oauthBroker: oauthBroker,
|
oauthBroker: oauthBroker,
|
||||||
}
|
}
|
||||||
|
|
||||||
wg.Go(service.CleanupOAuthSessionsRoutine)
|
|
||||||
|
|
||||||
return service
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *AuthService) SearchUser(username string) (*model.UserSearch, error) {
|
func (auth *AuthService) Init() error {
|
||||||
if auth.GetLocalUser(username) != nil {
|
go auth.CleanupOAuthSessionsRoutine()
|
||||||
return &model.UserSearch{
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (auth *AuthService) SearchUser(username string) config.UserSearch {
|
||||||
|
if auth.GetLocalUser(username).Username != "" {
|
||||||
|
return config.UserSearch{
|
||||||
Username: username,
|
Username: username,
|
||||||
Type: model.UserLocal,
|
Type: "local",
|
||||||
}, nil
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if auth.ldap != nil {
|
if auth.ldap.IsConfigured() {
|
||||||
userDN, err := auth.ldap.GetUserDN(username)
|
userDN, err := auth.ldap.GetUserDN(username)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get ldap user: %w", err)
|
tlog.App.Warn().Err(err).Str("username", username).Msg("Failed to search for user in LDAP")
|
||||||
|
return config.UserSearch{
|
||||||
|
Type: "unknown",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &model.UserSearch{
|
return config.UserSearch{
|
||||||
Username: userDN,
|
Username: userDN,
|
||||||
Type: model.UserLDAP,
|
Type: "ldap",
|
||||||
}, nil
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, ErrUserNotFound
|
return config.UserSearch{
|
||||||
|
Type: "unknown",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *AuthService) CheckUserPassword(search model.UserSearch, password string) error {
|
func (auth *AuthService) VerifyUser(search config.UserSearch, password string) bool {
|
||||||
switch search.Type {
|
switch search.Type {
|
||||||
case model.UserLocal:
|
case "local":
|
||||||
user := auth.GetLocalUser(search.Username)
|
user := auth.GetLocalUser(search.Username)
|
||||||
if user == nil {
|
return auth.CheckPassword(user, password)
|
||||||
return ErrUserNotFound
|
case "ldap":
|
||||||
}
|
if auth.ldap.IsConfigured() {
|
||||||
return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password))
|
|
||||||
case model.UserLDAP:
|
|
||||||
if auth.ldap != nil {
|
|
||||||
err := auth.ldap.Bind(search.Username, password)
|
err := auth.ldap.Bind(search.Username, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to bind to ldap user: %w", err)
|
tlog.App.Warn().Err(err).Str("username", search.Username).Msg("Failed to bind to LDAP")
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
err = auth.ldap.BindService(true)
|
err = auth.ldap.BindService(true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to bind to ldap service account: %w", err)
|
tlog.App.Error().Err(err).Msg("Failed to rebind with service account after user authentication")
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return true
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return errors.New("unknown user search type")
|
tlog.App.Debug().Str("type", search.Type).Msg("Unknown user type for authentication")
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
return errors.New("user authentication failed")
|
|
||||||
|
tlog.App.Warn().Str("username", search.Username).Msg("User authentication failed")
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *AuthService) GetLocalUser(username string) *model.LocalUser {
|
func (auth *AuthService) GetLocalUser(username string) config.User {
|
||||||
if auth.runtime.LocalUsers == nil {
|
for _, user := range auth.config.Users {
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for _, user := range auth.runtime.LocalUsers {
|
|
||||||
if user.Username == username {
|
if user.Username == username {
|
||||||
return &user
|
return user
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
|
tlog.App.Warn().Str("username", username).Msg("Local user not found")
|
||||||
|
return config.User{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *AuthService) GetLDAPUser(userDN string) (*model.LDAPUser, error) {
|
func (auth *AuthService) GetLdapUser(userDN string) (config.LdapUser, error) {
|
||||||
if auth.ldap == nil {
|
if !auth.ldap.IsConfigured() {
|
||||||
return nil, errors.New("ldap service not configured")
|
return config.LdapUser{}, errors.New("LDAP service not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
auth.ldapGroupsMutex.RLock()
|
auth.ldapGroupsMutex.RLock()
|
||||||
@@ -195,7 +195,7 @@ func (auth *AuthService) GetLDAPUser(userDN string) (*model.LDAPUser, error) {
|
|||||||
auth.ldapGroupsMutex.RUnlock()
|
auth.ldapGroupsMutex.RUnlock()
|
||||||
|
|
||||||
if exists && time.Now().Before(entry.Expires) {
|
if exists && time.Now().Before(entry.Expires) {
|
||||||
return &model.LDAPUser{
|
return config.LdapUser{
|
||||||
DN: userDN,
|
DN: userDN,
|
||||||
Groups: entry.Groups,
|
Groups: entry.Groups,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -204,22 +204,26 @@ func (auth *AuthService) GetLDAPUser(userDN string) (*model.LDAPUser, error) {
|
|||||||
groups, err := auth.ldap.GetUserGroups(userDN)
|
groups, err := auth.ldap.GetUserGroups(userDN)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get ldap groups: %w", err)
|
return config.LdapUser{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
auth.ldapGroupsMutex.Lock()
|
auth.ldapGroupsMutex.Lock()
|
||||||
auth.ldapGroupsCache[userDN] = &LdapGroupsCache{
|
auth.ldapGroupsCache[userDN] = &LdapGroupsCache{
|
||||||
Groups: groups,
|
Groups: groups,
|
||||||
Expires: time.Now().Add(time.Duration(auth.config.LDAP.GroupCacheTTL) * time.Second),
|
Expires: time.Now().Add(time.Duration(auth.config.LDAPGroupsCacheTTL) * time.Second),
|
||||||
}
|
}
|
||||||
auth.ldapGroupsMutex.Unlock()
|
auth.ldapGroupsMutex.Unlock()
|
||||||
|
|
||||||
return &model.LDAPUser{
|
return config.LdapUser{
|
||||||
DN: userDN,
|
DN: userDN,
|
||||||
Groups: groups,
|
Groups: groups,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (auth *AuthService) CheckPassword(user config.User, password string) bool {
|
||||||
|
return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) == nil
|
||||||
|
}
|
||||||
|
|
||||||
func (auth *AuthService) IsAccountLocked(identifier string) (bool, int) {
|
func (auth *AuthService) IsAccountLocked(identifier string) (bool, int) {
|
||||||
auth.loginMutex.RLock()
|
auth.loginMutex.RLock()
|
||||||
defer auth.loginMutex.RUnlock()
|
defer auth.loginMutex.RUnlock()
|
||||||
@@ -229,7 +233,7 @@ func (auth *AuthService) IsAccountLocked(identifier string) (bool, int) {
|
|||||||
return true, remaining
|
return true, remaining
|
||||||
}
|
}
|
||||||
|
|
||||||
if auth.config.Auth.LoginMaxRetries <= 0 || auth.config.Auth.LoginTimeout <= 0 {
|
if auth.config.LoginMaxRetries <= 0 || auth.config.LoginTimeout <= 0 {
|
||||||
return false, 0
|
return false, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,7 +251,7 @@ func (auth *AuthService) IsAccountLocked(identifier string) (bool, int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (auth *AuthService) RecordLoginAttempt(identifier string, success bool) {
|
func (auth *AuthService) RecordLoginAttempt(identifier string, success bool) {
|
||||||
if auth.config.Auth.LoginMaxRetries <= 0 || auth.config.Auth.LoginTimeout <= 0 {
|
if auth.config.LoginMaxRetries <= 0 || auth.config.LoginTimeout <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,21 +282,21 @@ func (auth *AuthService) RecordLoginAttempt(identifier string, success bool) {
|
|||||||
|
|
||||||
attempt.FailedAttempts++
|
attempt.FailedAttempts++
|
||||||
|
|
||||||
if attempt.FailedAttempts >= auth.config.Auth.LoginMaxRetries {
|
if attempt.FailedAttempts >= auth.config.LoginMaxRetries {
|
||||||
attempt.LockedUntil = time.Now().Add(time.Duration(auth.config.Auth.LoginTimeout) * time.Second)
|
attempt.LockedUntil = time.Now().Add(time.Duration(auth.config.LoginTimeout) * time.Second)
|
||||||
auth.log.App.Warn().Str("identifier", identifier).Int("failedAttempts", attempt.FailedAttempts).Msg("Account locked due to too many failed login attempts")
|
tlog.App.Warn().Str("identifier", identifier).Int("timeout", auth.config.LoginTimeout).Msg("Account locked due to too many failed login attempts")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *AuthService) IsEmailWhitelisted(email string) bool {
|
func (auth *AuthService) IsEmailWhitelisted(email string) bool {
|
||||||
return utils.CheckFilter(strings.Join(auth.runtime.OAuthWhitelist, ","), email)
|
return utils.CheckFilter(strings.Join(auth.config.OauthWhitelist, ","), email)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *AuthService) CreateSession(ctx context.Context, data repository.Session) (*http.Cookie, error) {
|
func (auth *AuthService) CreateSessionCookie(c *gin.Context, data *repository.Session) error {
|
||||||
uuid, err := uuid.NewRandom()
|
uuid, err := uuid.NewRandom()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to generate session uuid: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var expiry int
|
var expiry int
|
||||||
@@ -300,11 +304,9 @@ func (auth *AuthService) CreateSession(ctx context.Context, data repository.Sess
|
|||||||
if data.TotpPending {
|
if data.TotpPending {
|
||||||
expiry = 3600
|
expiry = 3600
|
||||||
} else {
|
} else {
|
||||||
expiry = auth.config.Auth.SessionExpiry
|
expiry = auth.config.SessionExpiry
|
||||||
}
|
}
|
||||||
|
|
||||||
expiresAt := time.Now().Add(time.Duration(expiry) * time.Second)
|
|
||||||
|
|
||||||
session := repository.CreateSessionParams{
|
session := repository.CreateSessionParams{
|
||||||
UUID: uuid.String(),
|
UUID: uuid.String(),
|
||||||
Username: data.Username,
|
Username: data.Username,
|
||||||
@@ -313,55 +315,53 @@ func (auth *AuthService) CreateSession(ctx context.Context, data repository.Sess
|
|||||||
Provider: data.Provider,
|
Provider: data.Provider,
|
||||||
TotpPending: data.TotpPending,
|
TotpPending: data.TotpPending,
|
||||||
OAuthGroups: data.OAuthGroups,
|
OAuthGroups: data.OAuthGroups,
|
||||||
Expiry: expiresAt.Unix(),
|
Expiry: time.Now().Add(time.Duration(expiry) * time.Second).Unix(),
|
||||||
CreatedAt: time.Now().Unix(),
|
CreatedAt: time.Now().Unix(),
|
||||||
OAuthName: data.OAuthName,
|
OAuthName: data.OAuthName,
|
||||||
OAuthSub: data.OAuthSub,
|
OAuthSub: data.OAuthSub,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = auth.queries.CreateSession(ctx, session)
|
_, err = auth.queries.CreateSession(c, session)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create session entry: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &http.Cookie{
|
c.SetCookie(auth.config.SessionCookieName, session.UUID, expiry, "/", fmt.Sprintf(".%s", auth.config.CookieDomain), auth.config.SecureCookie, true)
|
||||||
Name: auth.runtime.SessionCookieName,
|
|
||||||
Value: session.UUID,
|
return nil
|
||||||
Path: "/",
|
|
||||||
Domain: fmt.Sprintf(".%s", auth.runtime.CookieDomain),
|
|
||||||
Expires: expiresAt,
|
|
||||||
MaxAge: int(time.Until(expiresAt).Seconds()),
|
|
||||||
Secure: auth.config.Auth.SecureCookie,
|
|
||||||
HttpOnly: true,
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *AuthService) RefreshSession(ctx context.Context, uuid string) (*http.Cookie, error) {
|
func (auth *AuthService) RefreshSessionCookie(c *gin.Context) error {
|
||||||
session, err := auth.queries.GetSession(ctx, uuid)
|
cookie, err := c.Cookie(auth.config.SessionCookieName)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to retrieve session: %w", err)
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := auth.queries.GetSession(c, cookie)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
currentTime := time.Now().Unix()
|
currentTime := time.Now().Unix()
|
||||||
|
|
||||||
var refreshThreshold int64
|
var refreshThreshold int64
|
||||||
|
|
||||||
if auth.config.Auth.SessionExpiry <= int(time.Hour.Seconds()) {
|
if auth.config.SessionExpiry <= int(time.Hour.Seconds()) {
|
||||||
refreshThreshold = int64(auth.config.Auth.SessionExpiry / 2)
|
refreshThreshold = int64(auth.config.SessionExpiry / 2)
|
||||||
} else {
|
} else {
|
||||||
refreshThreshold = int64(time.Hour.Seconds())
|
refreshThreshold = int64(time.Hour.Seconds())
|
||||||
}
|
}
|
||||||
|
|
||||||
if session.Expiry-currentTime > refreshThreshold {
|
if session.Expiry-currentTime > refreshThreshold {
|
||||||
return nil, nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
newExpiry := session.Expiry + refreshThreshold
|
newExpiry := session.Expiry + refreshThreshold
|
||||||
|
|
||||||
_, err = auth.queries.UpdateSession(ctx, repository.UpdateSessionParams{
|
_, err = auth.queries.UpdateSession(c, repository.UpdateSessionParams{
|
||||||
Username: session.Username,
|
Username: session.Username,
|
||||||
Email: session.Email,
|
Email: session.Email,
|
||||||
Name: session.Name,
|
Name: session.Name,
|
||||||
@@ -375,160 +375,150 @@ func (auth *AuthService) RefreshSession(ctx context.Context, uuid string) (*http
|
|||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to update session expiry: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &http.Cookie{
|
c.SetCookie(auth.config.SessionCookieName, cookie, int(newExpiry-currentTime), "/", fmt.Sprintf(".%s", auth.config.CookieDomain), auth.config.SecureCookie, true)
|
||||||
Name: auth.runtime.SessionCookieName,
|
tlog.App.Trace().Str("username", session.Username).Msg("Session cookie refreshed")
|
||||||
Value: session.UUID,
|
|
||||||
Path: "/",
|
|
||||||
Domain: fmt.Sprintf(".%s", auth.runtime.CookieDomain),
|
|
||||||
Expires: time.Now().Add(time.Duration(newExpiry-currentTime) * time.Second),
|
|
||||||
MaxAge: int(newExpiry - currentTime),
|
|
||||||
Secure: auth.config.Auth.SecureCookie,
|
|
||||||
HttpOnly: true,
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
}, nil
|
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *AuthService) DeleteSession(ctx context.Context, uuid string) (*http.Cookie, error) {
|
func (auth *AuthService) DeleteSessionCookie(c *gin.Context) error {
|
||||||
err := auth.queries.DeleteSession(ctx, uuid)
|
cookie, err := c.Cookie(auth.config.SessionCookieName)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
auth.log.App.Error().Err(err).Str("uuid", uuid).Msg("Failed to delete session from database")
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &http.Cookie{
|
err = auth.queries.DeleteSession(c, cookie)
|
||||||
Name: auth.runtime.SessionCookieName,
|
|
||||||
Value: "",
|
if err != nil {
|
||||||
Path: "/",
|
return err
|
||||||
Domain: fmt.Sprintf(".%s", auth.runtime.CookieDomain),
|
}
|
||||||
Expires: time.Now(),
|
|
||||||
MaxAge: -1,
|
c.SetCookie(auth.config.SessionCookieName, "", -1, "/", fmt.Sprintf(".%s", auth.config.CookieDomain), auth.config.SecureCookie, true)
|
||||||
Secure: auth.config.Auth.SecureCookie,
|
|
||||||
HttpOnly: true,
|
return nil
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *AuthService) GetSession(ctx context.Context, uuid string) (*repository.Session, error) {
|
func (auth *AuthService) GetSessionCookie(c *gin.Context) (repository.Session, error) {
|
||||||
session, err := auth.queries.GetSession(ctx, uuid)
|
cookie, err := c.Cookie(auth.config.SessionCookieName)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return repository.Session{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := auth.queries.GetSession(c, cookie)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, errors.New("session not found")
|
return repository.Session{}, fmt.Errorf("session not found")
|
||||||
}
|
}
|
||||||
return nil, err
|
return repository.Session{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
currentTime := time.Now().Unix()
|
currentTime := time.Now().Unix()
|
||||||
|
|
||||||
if auth.config.Auth.SessionMaxLifetime != 0 && session.CreatedAt != 0 {
|
if auth.config.SessionMaxLifetime != 0 && session.CreatedAt != 0 {
|
||||||
if currentTime-session.CreatedAt > int64(auth.config.Auth.SessionMaxLifetime) {
|
if currentTime-session.CreatedAt > int64(auth.config.SessionMaxLifetime) {
|
||||||
err = auth.queries.DeleteSession(ctx, uuid)
|
err = auth.queries.DeleteSession(c, cookie)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to delete expired session: %w", err)
|
tlog.App.Error().Err(err).Msg("Failed to delete session exceeding max lifetime")
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("session max lifetime exceeded")
|
return repository.Session{}, fmt.Errorf("session expired due to max lifetime exceeded")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if currentTime > session.Expiry {
|
if currentTime > session.Expiry {
|
||||||
err = auth.queries.DeleteSession(ctx, uuid)
|
err = auth.queries.DeleteSession(c, cookie)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to delete expired session: %w", err)
|
tlog.App.Error().Err(err).Msg("Failed to delete expired session")
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("session expired")
|
return repository.Session{}, fmt.Errorf("session expired")
|
||||||
}
|
}
|
||||||
|
|
||||||
return &session, nil
|
return repository.Session{
|
||||||
|
UUID: session.UUID,
|
||||||
|
Username: session.Username,
|
||||||
|
Email: session.Email,
|
||||||
|
Name: session.Name,
|
||||||
|
Provider: session.Provider,
|
||||||
|
TotpPending: session.TotpPending,
|
||||||
|
OAuthGroups: session.OAuthGroups,
|
||||||
|
OAuthName: session.OAuthName,
|
||||||
|
OAuthSub: session.OAuthSub,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *AuthService) LocalAuthConfigured() bool {
|
func (auth *AuthService) LocalAuthConfigured() bool {
|
||||||
return len(auth.runtime.LocalUsers) > 0
|
return len(auth.config.Users) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *AuthService) LDAPAuthConfigured() bool {
|
func (auth *AuthService) LdapAuthConfigured() bool {
|
||||||
return auth.ldap != nil
|
return auth.ldap.IsConfigured()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *AuthService) IsUserAllowed(c *gin.Context, context model.UserContext, acls *model.App) bool {
|
func (auth *AuthService) IsUserAllowed(c *gin.Context, context config.UserContext, acls config.App) bool {
|
||||||
if acls == nil {
|
if context.OAuth {
|
||||||
return true
|
tlog.App.Debug().Msg("Checking OAuth whitelist")
|
||||||
}
|
return utils.CheckFilter(acls.OAuth.Whitelist, context.Email)
|
||||||
|
|
||||||
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 != "" {
|
if acls.Users.Block != "" {
|
||||||
auth.log.App.Debug().Msg("Checking users block list")
|
tlog.App.Debug().Msg("Checking blocked users")
|
||||||
if utils.CheckFilter(acls.Users.Block, context.GetUsername()) {
|
if utils.CheckFilter(acls.Users.Block, context.Username) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
auth.log.App.Debug().Msg("Checking users allow list")
|
tlog.App.Debug().Msg("Checking users")
|
||||||
return utils.CheckFilter(acls.Users.Allow, context.GetUsername())
|
return utils.CheckFilter(acls.Users.Allow, context.Username)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *AuthService) IsInOAuthGroup(c *gin.Context, context model.UserContext, acls *model.App) bool {
|
func (auth *AuthService) IsInOAuthGroup(c *gin.Context, context config.UserContext, requiredGroups string) bool {
|
||||||
if acls == nil {
|
if requiredGroups == "" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if !context.IsOAuth() {
|
for id := range config.OverrideProviders {
|
||||||
auth.log.App.Debug().Msg("User is not an OAuth user, skipping OAuth group check")
|
if context.Provider == id {
|
||||||
return false
|
tlog.App.Info().Str("provider", id).Msg("OAuth groups not supported for this provider")
|
||||||
}
|
|
||||||
|
|
||||||
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
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
auth.log.App.Debug().Msg("No groups matched")
|
for userGroup := range strings.SplitSeq(context.OAuthGroups, ",") {
|
||||||
return false
|
if utils.CheckFilter(requiredGroups, strings.TrimSpace(userGroup)) {
|
||||||
}
|
tlog.App.Trace().Str("group", userGroup).Str("required", requiredGroups).Msg("User group matched")
|
||||||
|
|
||||||
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
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
auth.log.App.Debug().Msg("No groups matched")
|
tlog.App.Debug().Msg("No groups matched")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *AuthService) IsAuthEnabled(uri string, acls *model.App) (bool, error) {
|
func (auth *AuthService) IsInLdapGroup(c *gin.Context, context config.UserContext, requiredGroups string) bool {
|
||||||
if acls == nil {
|
if requiredGroups == "" {
|
||||||
return true, nil
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for userGroup := range strings.SplitSeq(context.LdapGroups, ",") {
|
||||||
|
if utils.CheckFilter(requiredGroups, strings.TrimSpace(userGroup)) {
|
||||||
|
tlog.App.Trace().Str("group", userGroup).Str("required", requiredGroups).Msg("User group matched")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tlog.App.Debug().Msg("No groups matched")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (auth *AuthService) IsAuthEnabled(uri string, path config.AppPath) (bool, error) {
|
||||||
// Check for block list
|
// Check for block list
|
||||||
if acls.Path.Block != "" {
|
if path.Block != "" {
|
||||||
regex, err := regexp.Compile(acls.Path.Block)
|
regex, err := regexp.Compile(path.Block)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return true, err
|
return true, err
|
||||||
@@ -540,8 +530,8 @@ func (auth *AuthService) IsAuthEnabled(uri string, acls *model.App) (bool, error
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for allow list
|
// Check for allow list
|
||||||
if acls.Path.Allow != "" {
|
if path.Allow != "" {
|
||||||
regex, err := regexp.Compile(acls.Path.Allow)
|
regex, err := regexp.Compile(path.Allow)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return true, err
|
return true, err
|
||||||
@@ -555,23 +545,31 @@ func (auth *AuthService) IsAuthEnabled(uri string, acls *model.App) (bool, error
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *AuthService) CheckIP(ip string, acls *model.App) bool {
|
func (auth *AuthService) GetBasicAuth(c *gin.Context) *config.User {
|
||||||
if acls == nil {
|
username, password, ok := c.Request.BasicAuth()
|
||||||
return true
|
if !ok {
|
||||||
|
tlog.App.Debug().Msg("No basic auth provided")
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
return &config.User{
|
||||||
|
Username: username,
|
||||||
|
Password: password,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (auth *AuthService) CheckIP(acls config.AppIP, ip string) bool {
|
||||||
// Merge the global and app IP filter
|
// Merge the global and app IP filter
|
||||||
blockedIps := append(auth.config.Auth.IP.Block, acls.IP.Block...)
|
blockedIps := append(auth.config.IP.Block, acls.Block...)
|
||||||
allowedIPs := append(auth.config.Auth.IP.Allow, acls.IP.Allow...)
|
allowedIPs := append(auth.config.IP.Allow, acls.Allow...)
|
||||||
|
|
||||||
for _, blocked := range blockedIps {
|
for _, blocked := range blockedIps {
|
||||||
res, err := utils.FilterIP(blocked, ip)
|
res, err := utils.FilterIP(blocked, ip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
auth.log.App.Warn().Err(err).Str("item", blocked).Msg("Invalid IP/CIDR in block list")
|
tlog.App.Warn().Err(err).Str("item", blocked).Msg("Invalid IP/CIDR in block list")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if res {
|
if res {
|
||||||
auth.log.App.Debug().Str("ip", ip).Str("item", blocked).Msg("IP is in block list, denying access")
|
tlog.App.Debug().Str("ip", ip).Str("item", blocked).Msg("IP is in blocked list, denying access")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -579,42 +577,38 @@ func (auth *AuthService) CheckIP(ip string, acls *model.App) bool {
|
|||||||
for _, allowed := range allowedIPs {
|
for _, allowed := range allowedIPs {
|
||||||
res, err := utils.FilterIP(allowed, ip)
|
res, err := utils.FilterIP(allowed, ip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
auth.log.App.Warn().Err(err).Str("item", allowed).Msg("Invalid IP/CIDR in allow list")
|
tlog.App.Warn().Err(err).Str("item", allowed).Msg("Invalid IP/CIDR in allow list")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if res {
|
if res {
|
||||||
auth.log.App.Debug().Str("ip", ip).Str("item", allowed).Msg("IP is in allow list, allowing access")
|
tlog.App.Debug().Str("ip", ip).Str("item", allowed).Msg("IP is in allowed list, allowing access")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(allowedIPs) > 0 {
|
if len(allowedIPs) > 0 {
|
||||||
auth.log.App.Debug().Str("ip", ip).Msg("IP not in allow list, denying access")
|
tlog.App.Debug().Str("ip", ip).Msg("IP not in allow list, denying access")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
auth.log.App.Debug().Str("ip", ip).Msg("IP not in any block or allow list, allowing access by default")
|
tlog.App.Debug().Str("ip", ip).Msg("IP not in allow or block list, allowing by default")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *AuthService) IsBypassedIP(ip string, acls *model.App) bool {
|
func (auth *AuthService) IsBypassedIP(acls config.AppIP, ip string) bool {
|
||||||
if acls == nil {
|
for _, bypassed := range acls.Bypass {
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, bypassed := range acls.IP.Bypass {
|
|
||||||
res, err := utils.FilterIP(bypassed, ip)
|
res, err := utils.FilterIP(bypassed, ip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
auth.log.App.Warn().Err(err).Str("item", bypassed).Msg("Invalid IP/CIDR in bypass list")
|
tlog.App.Warn().Err(err).Str("item", bypassed).Msg("Invalid IP/CIDR in bypass list")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if res {
|
if res {
|
||||||
auth.log.App.Debug().Str("ip", ip).Str("item", bypassed).Msg("IP is in bypass list, skipping authentication")
|
tlog.App.Debug().Str("ip", ip).Str("item", bypassed).Msg("IP is in bypass list, allowing access")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
auth.log.App.Debug().Str("ip", ip).Msg("IP not in bypass list, proceeding with authentication")
|
tlog.App.Debug().Str("ip", ip).Msg("IP not in bypass list, continuing with authentication")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -681,21 +675,21 @@ func (auth *AuthService) GetOAuthToken(sessionId string, code string) (*oauth2.T
|
|||||||
return token, nil
|
return token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *AuthService) GetOAuthUserinfo(sessionId string) (*model.Claims, error) {
|
func (auth *AuthService) GetOAuthUserinfo(sessionId string) (config.Claims, error) {
|
||||||
session, err := auth.GetOAuthPendingSession(sessionId)
|
session, err := auth.GetOAuthPendingSession(sessionId)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return config.Claims{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if session.Token == nil {
|
if session.Token == nil {
|
||||||
return nil, fmt.Errorf("oauth token not found for session: %s", sessionId)
|
return config.Claims{}, fmt.Errorf("oauth token not found for session: %s", sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
userinfo, err := (*session.Service).GetUserinfo(session.Token)
|
userinfo, err := (*session.Service).GetUserinfo(session.Token)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get userinfo: %w", err)
|
return config.Claims{}, fmt.Errorf("failed to get userinfo: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return userinfo, nil
|
return userinfo, nil
|
||||||
@@ -718,32 +712,21 @@ func (auth *AuthService) EndOAuthSession(sessionId string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (auth *AuthService) CleanupOAuthSessionsRoutine() {
|
func (auth *AuthService) CleanupOAuthSessionsRoutine() {
|
||||||
auth.log.App.Debug().Msg("Starting OAuth session cleanup routine")
|
|
||||||
|
|
||||||
ticker := time.NewTicker(30 * time.Minute)
|
ticker := time.NewTicker(30 * time.Minute)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for {
|
for range ticker.C {
|
||||||
select {
|
auth.oauthMutex.Lock()
|
||||||
case <-ticker.C:
|
|
||||||
auth.log.App.Debug().Msg("Running OAuth session cleanup")
|
|
||||||
|
|
||||||
auth.oauthMutex.Lock()
|
now := time.Now()
|
||||||
|
|
||||||
now := time.Now()
|
for sessionId, session := range auth.oauthPendingSessions {
|
||||||
|
if now.After(session.ExpiresAt) {
|
||||||
for sessionId, session := range auth.oauthPendingSessions {
|
delete(auth.oauthPendingSessions, sessionId)
|
||||||
if now.After(session.ExpiresAt) {
|
|
||||||
delete(auth.oauthPendingSessions, sessionId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
auth.oauthMutex.Unlock()
|
|
||||||
auth.log.App.Debug().Msg("OAuth session cleanup completed")
|
|
||||||
case <-auth.context.Done():
|
|
||||||
auth.log.App.Debug().Msg("Stopping OAuth session cleanup routine")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auth.oauthMutex.Unlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -812,11 +795,11 @@ func (auth *AuthService) lockdownMode() {
|
|||||||
|
|
||||||
auth.loginMutex.Lock()
|
auth.loginMutex.Lock()
|
||||||
|
|
||||||
auth.log.App.Warn().Msg("Too many failed login attempts, entering lockdown mode")
|
tlog.App.Warn().Msg("Multiple login attempts detected, possibly DDOS attack. Activating temporary lockdown.")
|
||||||
|
|
||||||
auth.lockdown = &Lockdown{
|
auth.lockdown = &Lockdown{
|
||||||
Active: true,
|
Active: true,
|
||||||
ActiveUntil: time.Now().Add(time.Duration(auth.config.Auth.LoginTimeout) * time.Second),
|
ActiveUntil: time.Now().Add(time.Duration(auth.config.LoginTimeout) * time.Second),
|
||||||
}
|
}
|
||||||
|
|
||||||
// At this point all login attemps will also expire so,
|
// At this point all login attemps will also expire so,
|
||||||
@@ -833,14 +816,11 @@ func (auth *AuthService) lockdownMode() {
|
|||||||
// Timer expired, end lockdown
|
// Timer expired, end lockdown
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
// Context cancelled, end lockdown
|
// Context cancelled, end lockdown
|
||||||
case <-auth.context.Done():
|
|
||||||
// Service is shutting down, end lockdown
|
|
||||||
}
|
}
|
||||||
|
|
||||||
auth.loginMutex.Lock()
|
auth.loginMutex.Lock()
|
||||||
|
|
||||||
auth.log.App.Info().Msg("Exiting lockdown mode")
|
tlog.App.Info().Msg("Lockdown period ended, resuming normal operation")
|
||||||
|
|
||||||
auth.lockdown = nil
|
auth.lockdown = nil
|
||||||
auth.loginMutex.Unlock()
|
auth.loginMutex.Unlock()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,112 +3,104 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
"github.com/steveiliop56/tinyauth/internal/config"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/decoders"
|
"github.com/steveiliop56/tinyauth/internal/utils/decoders"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
|
|
||||||
container "github.com/docker/docker/api/types/container"
|
container "github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/client"
|
"github.com/docker/docker/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DockerService struct {
|
type DockerService struct {
|
||||||
log *logger.Logger
|
client *client.Client
|
||||||
client *client.Client
|
context context.Context
|
||||||
context context.Context
|
|
||||||
|
|
||||||
isConnected bool
|
isConnected bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDockerService(
|
func NewDockerService() *DockerService {
|
||||||
log *logger.Logger,
|
return &DockerService{}
|
||||||
ctx context.Context,
|
}
|
||||||
wg *sync.WaitGroup,
|
|
||||||
) (*DockerService, error) {
|
|
||||||
|
|
||||||
|
func (docker *DockerService) Init() error {
|
||||||
client, err := client.NewClientWithOpts(client.FromEnv)
|
client, err := client.NewClientWithOpts(client.FromEnv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
client.NegotiateAPIVersion(ctx)
|
client.NegotiateAPIVersion(ctx)
|
||||||
|
|
||||||
_, err = client.Ping(ctx)
|
docker.client = client
|
||||||
|
docker.context = ctx
|
||||||
|
|
||||||
|
_, err = docker.client.Ping(docker.context)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.App.Debug().Err(err).Msg("Docker not connected")
|
tlog.App.Debug().Err(err).Msg("Docker not connected")
|
||||||
return nil, nil
|
docker.isConnected = false
|
||||||
|
docker.client = nil
|
||||||
|
docker.context = nil
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
service := &DockerService{
|
docker.isConnected = true
|
||||||
log: log,
|
tlog.App.Debug().Msg("Docker connected")
|
||||||
client: client,
|
|
||||||
context: ctx,
|
|
||||||
}
|
|
||||||
|
|
||||||
service.isConnected = true
|
return nil
|
||||||
service.log.App.Debug().Msg("Docker connected successfully")
|
|
||||||
|
|
||||||
wg.Go(service.watchAndClose)
|
|
||||||
|
|
||||||
return service, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (docker *DockerService) getContainers() ([]container.Summary, error) {
|
func (docker *DockerService) getContainers() ([]container.Summary, error) {
|
||||||
return docker.client.ContainerList(docker.context, container.ListOptions{})
|
containers, err := docker.client.ContainerList(docker.context, container.ListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return containers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (docker *DockerService) inspectContainer(containerId string) (container.InspectResponse, error) {
|
func (docker *DockerService) inspectContainer(containerId string) (container.InspectResponse, error) {
|
||||||
return docker.client.ContainerInspect(docker.context, containerId)
|
inspect, err := docker.client.ContainerInspect(docker.context, containerId)
|
||||||
|
if err != nil {
|
||||||
|
return container.InspectResponse{}, err
|
||||||
|
}
|
||||||
|
return inspect, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (docker *DockerService) GetLabels(appDomain string) (*model.App, error) {
|
func (docker *DockerService) GetLabels(appDomain string) (config.App, error) {
|
||||||
if !docker.isConnected {
|
if !docker.isConnected {
|
||||||
docker.log.App.Debug().Msg("Docker service not connected, returning empty labels")
|
tlog.App.Debug().Msg("Docker not connected, returning empty labels")
|
||||||
return nil, nil
|
return config.App{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
containers, err := docker.getContainers()
|
containers, err := docker.getContainers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return config.App{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, ctr := range containers {
|
for _, ctr := range containers {
|
||||||
inspect, err := docker.inspectContainer(ctr.ID)
|
inspect, err := docker.inspectContainer(ctr.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return config.App{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
labels, err := decoders.DecodeLabels[model.Apps](inspect.Config.Labels, "apps")
|
labels, err := decoders.DecodeLabels[config.Apps](inspect.Config.Labels, "apps")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return config.App{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
for appName, appLabels := range labels.Apps {
|
for appName, appLabels := range labels.Apps {
|
||||||
if appLabels.Config.Domain == appDomain {
|
if appLabels.Config.Domain == appDomain {
|
||||||
docker.log.App.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by domain")
|
tlog.App.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by domain")
|
||||||
return &appLabels, nil
|
return appLabels, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.SplitN(appDomain, ".", 2)[0] == appName {
|
if strings.SplitN(appDomain, ".", 2)[0] == appName {
|
||||||
docker.log.App.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by app name")
|
tlog.App.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by app name")
|
||||||
return &appLabels, nil
|
return appLabels, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
docker.log.App.Debug().Str("domain", appDomain).Msg("No matching container found for domain")
|
tlog.App.Debug().Msg("No matching container found, returning empty labels")
|
||||||
return nil, nil
|
return config.App{}, nil
|
||||||
}
|
|
||||||
|
|
||||||
func (docker *DockerService) watchAndClose() {
|
|
||||||
<-docker.context.Done()
|
|
||||||
docker.log.App.Debug().Msg("Closing Docker client")
|
|
||||||
if docker.client != nil {
|
|
||||||
err := docker.client.Close()
|
|
||||||
if err != nil {
|
|
||||||
docker.log.App.Error().Err(err).Msg("Error closing Docker client")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,310 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/decoders"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
|
||||||
|
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
||||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
||||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
|
||||||
"k8s.io/apimachinery/pkg/watch"
|
|
||||||
"k8s.io/client-go/dynamic"
|
|
||||||
"k8s.io/client-go/rest"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ingressKey struct {
|
|
||||||
namespace string
|
|
||||||
name string
|
|
||||||
}
|
|
||||||
|
|
||||||
type ingressAppKey struct {
|
|
||||||
ingressKey
|
|
||||||
appName string
|
|
||||||
}
|
|
||||||
|
|
||||||
type ingressApp struct {
|
|
||||||
domain string
|
|
||||||
appName string
|
|
||||||
app model.App
|
|
||||||
}
|
|
||||||
|
|
||||||
type KubernetesService struct {
|
|
||||||
log *logger.Logger
|
|
||||||
ctx context.Context
|
|
||||||
|
|
||||||
client dynamic.Interface
|
|
||||||
started bool
|
|
||||||
mu sync.RWMutex
|
|
||||||
ingressApps map[ingressKey][]ingressApp
|
|
||||||
domainIndex map[string]ingressAppKey
|
|
||||||
appNameIndex map[string]ingressAppKey
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewKubernetesService(
|
|
||||||
log *logger.Logger,
|
|
||||||
ctx context.Context,
|
|
||||||
wg *sync.WaitGroup,
|
|
||||||
) (*KubernetesService, error) {
|
|
||||||
cfg, err := rest.InClusterConfig()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get in-cluster kubernetes config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := dynamic.NewForConfig(cfg)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create kubernetes client: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
gvr := schema.GroupVersionResource{
|
|
||||||
Group: "networking.k8s.io",
|
|
||||||
Version: "v1",
|
|
||||||
Resource: "ingresses",
|
|
||||||
}
|
|
||||||
|
|
||||||
accessCtx, accessCancel := context.WithTimeout(ctx, 5*time.Second)
|
|
||||||
defer accessCancel()
|
|
||||||
|
|
||||||
_, err = client.Resource(gvr).List(accessCtx, metav1.ListOptions{Limit: 1})
|
|
||||||
if err != nil {
|
|
||||||
log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to access Ingress API, Kubernetes label provider will be disabled")
|
|
||||||
return nil, fmt.Errorf("failed to access ingress api: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Successfully accessed Ingress API, starting watcher")
|
|
||||||
|
|
||||||
service := &KubernetesService{
|
|
||||||
log: log,
|
|
||||||
ctx: ctx,
|
|
||||||
client: client,
|
|
||||||
ingressApps: make(map[ingressKey][]ingressApp),
|
|
||||||
domainIndex: make(map[string]ingressAppKey),
|
|
||||||
appNameIndex: make(map[string]ingressAppKey),
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Go(func() {
|
|
||||||
service.watchGVR(gvr)
|
|
||||||
})
|
|
||||||
|
|
||||||
service.started = true
|
|
||||||
log.App.Debug().Msg("Kubernetes label provider started successfully")
|
|
||||||
|
|
||||||
return service, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (k *KubernetesService) addIngressApps(namespace, name string, apps []ingressApp) {
|
|
||||||
k.mu.Lock()
|
|
||||||
defer k.mu.Unlock()
|
|
||||||
|
|
||||||
key := ingressKey{namespace, name}
|
|
||||||
// Remove existing entries for this ingress
|
|
||||||
if existing, ok := k.ingressApps[key]; ok {
|
|
||||||
for _, app := range existing {
|
|
||||||
delete(k.domainIndex, app.domain)
|
|
||||||
delete(k.appNameIndex, app.appName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Add new entries
|
|
||||||
k.ingressApps[key] = apps
|
|
||||||
for _, app := range apps {
|
|
||||||
appKey := ingressAppKey{key, app.appName}
|
|
||||||
k.domainIndex[app.domain] = appKey
|
|
||||||
k.appNameIndex[app.appName] = appKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (k *KubernetesService) removeIngress(namespace, name string) {
|
|
||||||
k.mu.Lock()
|
|
||||||
defer k.mu.Unlock()
|
|
||||||
|
|
||||||
key := ingressKey{namespace, name}
|
|
||||||
if apps, ok := k.ingressApps[key]; ok {
|
|
||||||
for _, app := range apps {
|
|
||||||
delete(k.domainIndex, app.domain)
|
|
||||||
delete(k.appNameIndex, app.appName)
|
|
||||||
}
|
|
||||||
delete(k.ingressApps, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (k *KubernetesService) getByDomain(domain string) *model.App {
|
|
||||||
k.mu.RLock()
|
|
||||||
defer k.mu.RUnlock()
|
|
||||||
|
|
||||||
if appKey, ok := k.domainIndex[domain]; ok {
|
|
||||||
if apps, ok := k.ingressApps[appKey.ingressKey]; ok {
|
|
||||||
for i := range apps {
|
|
||||||
app := &apps[i]
|
|
||||||
if app.domain == domain && app.appName == appKey.appName {
|
|
||||||
return &app.app
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (k *KubernetesService) getByAppName(appName string) *model.App {
|
|
||||||
k.mu.RLock()
|
|
||||||
defer k.mu.RUnlock()
|
|
||||||
|
|
||||||
if appKey, ok := k.appNameIndex[appName]; ok {
|
|
||||||
if apps, ok := k.ingressApps[appKey.ingressKey]; ok {
|
|
||||||
for i := range apps {
|
|
||||||
app := &apps[i]
|
|
||||||
if app.appName == appName {
|
|
||||||
return &app.app
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) {
|
|
||||||
namespace := item.GetNamespace()
|
|
||||||
name := item.GetName()
|
|
||||||
annotations := item.GetAnnotations()
|
|
||||||
if annotations == nil {
|
|
||||||
k.removeIngress(namespace, name)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
labels, err := decoders.DecodeLabels[model.Apps](annotations, "apps")
|
|
||||||
if err != nil {
|
|
||||||
k.log.App.Warn().Err(err).Str("namespace", namespace).Str("name", name).Msg("Failed to decode ingress labels, skipping")
|
|
||||||
k.removeIngress(namespace, name)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var apps []ingressApp
|
|
||||||
for appName, appLabels := range labels.Apps {
|
|
||||||
if appLabels.Config.Domain == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
apps = append(apps, ingressApp{
|
|
||||||
domain: appLabels.Config.Domain,
|
|
||||||
appName: appName,
|
|
||||||
app: appLabels,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if len(apps) == 0 {
|
|
||||||
k.removeIngress(namespace, name)
|
|
||||||
} else {
|
|
||||||
k.addIngressApps(namespace, name, apps)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (k *KubernetesService) resyncGVR(gvr schema.GroupVersionResource) error {
|
|
||||||
ctx, cancel := context.WithTimeout(k.ctx, 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
list, err := k.client.Resource(gvr).List(ctx, metav1.ListOptions{})
|
|
||||||
if err != nil {
|
|
||||||
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to list resources for resync")
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for i := range list.Items {
|
|
||||||
k.updateFromItem(&list.Items[i])
|
|
||||||
}
|
|
||||||
k.log.App.Debug().Str("api", gvr.GroupVersion().String()).Int("count", len(list.Items)).Msg("Resync complete")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// runWatcher drains events from an active watcher until it closes or the context is done.
|
|
||||||
// Returns true if the caller should restart the watcher, false if it should exit.
|
|
||||||
func (k *KubernetesService) runWatcher(gvr schema.GroupVersionResource, w watch.Interface, resyncTicker *time.Ticker) bool {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-k.ctx.Done():
|
|
||||||
w.Stop()
|
|
||||||
return false
|
|
||||||
case event, ok := <-w.ResultChan():
|
|
||||||
if !ok {
|
|
||||||
k.log.App.Warn().Str("api", gvr.GroupVersion().String()).Msg("Watcher channel closed, restarting watcher")
|
|
||||||
w.Stop()
|
|
||||||
time.Sleep(5 * time.Second)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
item, ok := event.Object.(*unstructured.Unstructured)
|
|
||||||
if !ok {
|
|
||||||
k.log.App.Warn().Str("api", gvr.GroupVersion().String()).Msg("Received unexpected event object, skipping")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch event.Type {
|
|
||||||
case watch.Added, watch.Modified:
|
|
||||||
k.updateFromItem(item)
|
|
||||||
case watch.Deleted:
|
|
||||||
k.removeIngress(item.GetNamespace(), item.GetName())
|
|
||||||
}
|
|
||||||
case <-resyncTicker.C:
|
|
||||||
if err := k.resyncGVR(gvr); err != nil {
|
|
||||||
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Periodic resync failed during watcher run")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (k *KubernetesService) watchGVR(gvr schema.GroupVersionResource) {
|
|
||||||
resyncTicker := time.NewTicker(5 * time.Minute)
|
|
||||||
defer resyncTicker.Stop()
|
|
||||||
|
|
||||||
if err := k.resyncGVR(gvr); err != nil {
|
|
||||||
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Initial resync failed, will retry")
|
|
||||||
time.Sleep(30 * time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-k.ctx.Done():
|
|
||||||
k.log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Shutting down kubernetes watcher")
|
|
||||||
return
|
|
||||||
case <-resyncTicker.C:
|
|
||||||
if err := k.resyncGVR(gvr); err != nil {
|
|
||||||
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Periodic resync failed, will retry")
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
ctx, cancel := context.WithCancel(k.ctx)
|
|
||||||
watcher, err := k.client.Resource(gvr).Watch(ctx, metav1.ListOptions{})
|
|
||||||
if err != nil {
|
|
||||||
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to start watcher, will retry")
|
|
||||||
cancel()
|
|
||||||
time.Sleep(10 * time.Second)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
k.log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Watcher started successfully")
|
|
||||||
if !k.runWatcher(gvr, watcher, resyncTicker) {
|
|
||||||
cancel()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (k *KubernetesService) GetLabels(appDomain string) (*model.App, error) {
|
|
||||||
if !k.started {
|
|
||||||
k.log.App.Debug().Str("domain", appDomain).Msg("Kubernetes label provider not started, skipping")
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// First check cache
|
|
||||||
app := k.getByDomain(appDomain)
|
|
||||||
if app != nil {
|
|
||||||
k.log.App.Debug().Str("domain", appDomain).Msg("Found labels in cache by domain")
|
|
||||||
return app, nil
|
|
||||||
}
|
|
||||||
appName := strings.SplitN(appDomain, ".", 2)[0]
|
|
||||||
app = k.getByAppName(appName)
|
|
||||||
if app != nil {
|
|
||||||
k.log.App.Debug().Str("domain", appDomain).Str("appName", appName).Msg("Found labels in cache by app name")
|
|
||||||
return app, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
k.log.App.Debug().Str("domain", appDomain).Msg("No labels found for domain")
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestKubernetesService(t *testing.T) {
|
|
||||||
log := logger.NewLogger().WithTestConfig()
|
|
||||||
log.Init()
|
|
||||||
|
|
||||||
type testCase struct {
|
|
||||||
description string
|
|
||||||
run func(t *testing.T, svc *KubernetesService)
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []testCase{
|
|
||||||
{
|
|
||||||
description: "Cache by domain returns app and misses unknown domain",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
app := model.App{Config: model.AppConfig{Domain: "foo.example.com"}}
|
|
||||||
svc.addIngressApps("default", "my-ingress", []ingressApp{
|
|
||||||
{domain: "foo.example.com", appName: "foo", app: app},
|
|
||||||
})
|
|
||||||
|
|
||||||
got := svc.getByDomain("foo.example.com")
|
|
||||||
require.NotNil(t, got)
|
|
||||||
assert.Equal(t, "foo.example.com", got.Config.Domain)
|
|
||||||
|
|
||||||
got = svc.getByDomain("notfound.example.com")
|
|
||||||
assert.Nil(t, got)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Cache by app name returns app and misses unknown name",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
app := model.App{Config: model.AppConfig{Domain: "bar.example.com"}}
|
|
||||||
svc.addIngressApps("default", "my-ingress", []ingressApp{
|
|
||||||
{domain: "bar.example.com", appName: "bar", app: app},
|
|
||||||
})
|
|
||||||
|
|
||||||
got := svc.getByAppName("bar")
|
|
||||||
require.NotNil(t, got)
|
|
||||||
assert.Equal(t, "bar.example.com", got.Config.Domain)
|
|
||||||
|
|
||||||
got = svc.getByAppName("notfound")
|
|
||||||
assert.Nil(t, got)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "RemoveIngress clears domain and app name entries",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
app := model.App{Config: model.AppConfig{Domain: "baz.example.com"}}
|
|
||||||
svc.addIngressApps("default", "my-ingress", []ingressApp{
|
|
||||||
{domain: "baz.example.com", appName: "baz", app: app},
|
|
||||||
})
|
|
||||||
|
|
||||||
svc.removeIngress("default", "my-ingress")
|
|
||||||
|
|
||||||
got := svc.getByDomain("baz.example.com")
|
|
||||||
assert.Nil(t, got)
|
|
||||||
got = svc.getByAppName("baz")
|
|
||||||
assert.Nil(t, got)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "AddIngressApps replaces stale entries for the same ingress",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
old := model.App{Config: model.AppConfig{Domain: "old.example.com"}}
|
|
||||||
svc.addIngressApps("default", "my-ingress", []ingressApp{
|
|
||||||
{domain: "old.example.com", appName: "old", app: old},
|
|
||||||
})
|
|
||||||
|
|
||||||
updated := model.App{Config: model.AppConfig{Domain: "new.example.com"}}
|
|
||||||
svc.addIngressApps("default", "my-ingress", []ingressApp{
|
|
||||||
{domain: "new.example.com", appName: "new", app: updated},
|
|
||||||
})
|
|
||||||
|
|
||||||
got := svc.getByDomain("old.example.com")
|
|
||||||
assert.Nil(t, got)
|
|
||||||
|
|
||||||
got = svc.getByDomain("new.example.com")
|
|
||||||
require.NotNil(t, got)
|
|
||||||
assert.Equal(t, "new.example.com", got.Config.Domain)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "GetLabels returns app from cache when started",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
svc.started = true
|
|
||||||
|
|
||||||
app := model.App{Config: model.AppConfig{Domain: "hit.example.com"}}
|
|
||||||
svc.addIngressApps("default", "ing", []ingressApp{
|
|
||||||
{domain: "hit.example.com", appName: "hit", app: app},
|
|
||||||
})
|
|
||||||
|
|
||||||
got, err := svc.GetLabels("hit.example.com")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "hit.example.com", got.Config.Domain)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "GetLabels returns empty app on cache miss when started",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
svc.started = true
|
|
||||||
|
|
||||||
got, err := svc.GetLabels("notfound.example.com")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Nil(t, got)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "GetLabels resolves app by app name",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
svc.started = true
|
|
||||||
|
|
||||||
app := model.App{Config: model.AppConfig{Domain: "myapp.internal.example.com"}}
|
|
||||||
svc.addIngressApps("default", "ing", []ingressApp{
|
|
||||||
{domain: "myapp.internal.example.com", appName: "myapp", app: app},
|
|
||||||
})
|
|
||||||
|
|
||||||
got, err := svc.GetLabels("myapp.internal.example.com")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "myapp.internal.example.com", got.Config.Domain)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "GetLabels returns empty app when service not yet started",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
got, err := svc.GetLabels("anything.example.com")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Nil(t, got)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "UpdateFromItem parses annotations and populates cache",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
item := unstructured.Unstructured{}
|
|
||||||
item.SetNamespace("default")
|
|
||||||
item.SetName("test-ingress")
|
|
||||||
item.SetAnnotations(map[string]string{
|
|
||||||
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
|
|
||||||
"tinyauth.apps.myapp.users.allow": "alice",
|
|
||||||
})
|
|
||||||
|
|
||||||
svc.updateFromItem(&item)
|
|
||||||
|
|
||||||
got := svc.getByDomain("myapp.example.com")
|
|
||||||
require.NotNil(t, got)
|
|
||||||
assert.Equal(t, "myapp.example.com", got.Config.Domain)
|
|
||||||
assert.Equal(t, "alice", got.Users.Allow)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "UpdateFromItem with no annotations removes existing cache entries",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
app := model.App{Config: model.AppConfig{Domain: "todelete.example.com"}}
|
|
||||||
svc.addIngressApps("default", "test-ingress", []ingressApp{
|
|
||||||
{domain: "todelete.example.com", appName: "todelete", app: app},
|
|
||||||
})
|
|
||||||
|
|
||||||
item := unstructured.Unstructured{}
|
|
||||||
item.SetNamespace("default")
|
|
||||||
item.SetName("test-ingress")
|
|
||||||
|
|
||||||
svc.updateFromItem(&item)
|
|
||||||
|
|
||||||
got := svc.getByDomain("todelete.example.com")
|
|
||||||
assert.Nil(t, got)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.description, func(t *testing.T) {
|
|
||||||
svc := &KubernetesService{
|
|
||||||
ingressApps: make(map[ingressKey][]ingressApp),
|
|
||||||
domainIndex: make(map[string]ingressAppKey),
|
|
||||||
appNameIndex: make(map[string]ingressAppKey),
|
|
||||||
log: log,
|
|
||||||
}
|
|
||||||
test.run(t, svc)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,47 +9,69 @@ import (
|
|||||||
|
|
||||||
"github.com/cenkalti/backoff/v5"
|
"github.com/cenkalti/backoff/v5"
|
||||||
ldapgo "github.com/go-ldap/ldap/v3"
|
ldapgo "github.com/go-ldap/ldap/v3"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type LdapService struct {
|
type LdapServiceConfig struct {
|
||||||
log *logger.Logger
|
Address string
|
||||||
config model.Config
|
BindDN string
|
||||||
context context.Context
|
BindPassword string
|
||||||
|
BaseDN string
|
||||||
conn *ldapgo.Conn
|
Insecure bool
|
||||||
mutex sync.RWMutex
|
SearchFilter string
|
||||||
cert *tls.Certificate
|
AuthCert string
|
||||||
|
AuthKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewLdapService(
|
type LdapService struct {
|
||||||
log *logger.Logger,
|
config LdapServiceConfig
|
||||||
config model.Config,
|
conn *ldapgo.Conn
|
||||||
ctx context.Context,
|
mutex sync.RWMutex
|
||||||
wg *sync.WaitGroup,
|
cert *tls.Certificate
|
||||||
) (*LdapService, error) {
|
isConfigured bool
|
||||||
if config.LDAP.Address == "" {
|
}
|
||||||
return nil, nil
|
|
||||||
|
func NewLdapService(config LdapServiceConfig) *LdapService {
|
||||||
|
return &LdapService{
|
||||||
|
config: config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ldap *LdapService) IsConfigured() bool {
|
||||||
|
return ldap.isConfigured
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ldap *LdapService) Unconfigure() error {
|
||||||
|
if !ldap.isConfigured {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
ldap := &LdapService{
|
if ldap.conn != nil {
|
||||||
log: log,
|
if err := ldap.conn.Close(); err != nil {
|
||||||
config: config,
|
return fmt.Errorf("failed to close LDAP connection: %w", err)
|
||||||
context: ctx,
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ldap.isConfigured = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ldap *LdapService) Init() error {
|
||||||
|
if ldap.config.Address == "" {
|
||||||
|
ldap.isConfigured = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ldap.isConfigured = true
|
||||||
|
|
||||||
// Check whether authentication with client certificate is possible
|
// Check whether authentication with client certificate is possible
|
||||||
if config.LDAP.AuthCert != "" && config.LDAP.AuthKey != "" {
|
if ldap.config.AuthCert != "" && ldap.config.AuthKey != "" {
|
||||||
cert, err := tls.LoadX509KeyPair(config.LDAP.AuthCert, config.LDAP.AuthKey)
|
cert, err := tls.LoadX509KeyPair(ldap.config.AuthCert, ldap.config.AuthKey)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to initialize LDAP with mTLS authentication: %w", err)
|
return fmt.Errorf("failed to initialize LDAP with mTLS authentication: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.App.Info().Msg("LDAP mTLS authentication configured successfully")
|
|
||||||
|
|
||||||
ldap.cert = &cert
|
ldap.cert = &cert
|
||||||
|
tlog.App.Info().Msg("Using LDAP with mTLS authentication")
|
||||||
|
|
||||||
// TODO: Add optional extra CA certificates, instead of `InsecureSkipVerify`
|
// TODO: Add optional extra CA certificates, instead of `InsecureSkipVerify`
|
||||||
/*
|
/*
|
||||||
@@ -62,39 +84,26 @@ func NewLdapService(
|
|||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := ldap.connect()
|
_, err := ldap.connect()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to connect to ldap server: %w", err)
|
return fmt.Errorf("failed to connect to LDAP server: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
wg.Go(func() {
|
go func() {
|
||||||
ldap.log.App.Debug().Msg("Starting LDAP connection heartbeat routine")
|
for range time.Tick(time.Duration(5) * time.Minute) {
|
||||||
|
err := ldap.heartbeat()
|
||||||
ticker := time.NewTicker(5 * time.Minute)
|
if err != nil {
|
||||||
defer ticker.Stop()
|
tlog.App.Error().Err(err).Msg("LDAP connection heartbeat failed")
|
||||||
|
if reconnectErr := ldap.reconnect(); reconnectErr != nil {
|
||||||
for {
|
tlog.App.Error().Err(reconnectErr).Msg("Failed to reconnect to LDAP server")
|
||||||
select {
|
continue
|
||||||
case <-ticker.C:
|
|
||||||
err := ldap.heartbeat()
|
|
||||||
if err != nil {
|
|
||||||
ldap.log.App.Warn().Err(err).Msg("LDAP connection heartbeat failed, attempting to reconnect")
|
|
||||||
if reconnectErr := ldap.reconnect(); reconnectErr != nil {
|
|
||||||
ldap.log.App.Error().Err(reconnectErr).Msg("Failed to reconnect to LDAP server")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ldap.log.App.Info().Msg("Successfully reconnected to LDAP server")
|
|
||||||
}
|
}
|
||||||
case <-ldap.context.Done():
|
tlog.App.Info().Msg("Successfully reconnected to LDAP server")
|
||||||
ldap.log.App.Debug().Msg("LDAP service context cancelled, stopping heartbeat")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
}()
|
||||||
|
|
||||||
return ldap, nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ldap *LdapService) connect() (*ldapgo.Conn, error) {
|
func (ldap *LdapService) connect() (*ldapgo.Conn, error) {
|
||||||
@@ -111,13 +120,13 @@ func (ldap *LdapService) connect() (*ldapgo.Conn, error) {
|
|||||||
// 2. conn.StartTLS(tlsConfig)
|
// 2. conn.StartTLS(tlsConfig)
|
||||||
// 3. conn.externalBind()
|
// 3. conn.externalBind()
|
||||||
if ldap.cert != nil {
|
if ldap.cert != nil {
|
||||||
conn, err = ldapgo.DialURL(ldap.config.LDAP.Address, ldapgo.DialWithTLSConfig(&tls.Config{
|
conn, err = ldapgo.DialURL(ldap.config.Address, ldapgo.DialWithTLSConfig(&tls.Config{
|
||||||
MinVersion: tls.VersionTLS12,
|
MinVersion: tls.VersionTLS12,
|
||||||
Certificates: []tls.Certificate{*ldap.cert},
|
Certificates: []tls.Certificate{*ldap.cert},
|
||||||
}))
|
}))
|
||||||
} else {
|
} else {
|
||||||
conn, err = ldapgo.DialURL(ldap.config.LDAP.Address, ldapgo.DialWithTLSConfig(&tls.Config{
|
conn, err = ldapgo.DialURL(ldap.config.Address, ldapgo.DialWithTLSConfig(&tls.Config{
|
||||||
InsecureSkipVerify: ldap.config.LDAP.Insecure,
|
InsecureSkipVerify: ldap.config.Insecure,
|
||||||
MinVersion: tls.VersionTLS12,
|
MinVersion: tls.VersionTLS12,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -137,10 +146,10 @@ func (ldap *LdapService) connect() (*ldapgo.Conn, error) {
|
|||||||
func (ldap *LdapService) GetUserDN(username string) (string, error) {
|
func (ldap *LdapService) GetUserDN(username string) (string, error) {
|
||||||
// Escape the username to prevent LDAP injection
|
// Escape the username to prevent LDAP injection
|
||||||
escapedUsername := ldapgo.EscapeFilter(username)
|
escapedUsername := ldapgo.EscapeFilter(username)
|
||||||
filter := fmt.Sprintf(ldap.config.LDAP.SearchFilter, escapedUsername)
|
filter := fmt.Sprintf(ldap.config.SearchFilter, escapedUsername)
|
||||||
|
|
||||||
searchRequest := ldapgo.NewSearchRequest(
|
searchRequest := ldapgo.NewSearchRequest(
|
||||||
ldap.config.LDAP.BaseDN,
|
ldap.config.BaseDN,
|
||||||
ldapgo.ScopeWholeSubtree, ldapgo.NeverDerefAliases, 0, 0, false,
|
ldapgo.ScopeWholeSubtree, ldapgo.NeverDerefAliases, 0, 0, false,
|
||||||
filter,
|
filter,
|
||||||
[]string{"dn"},
|
[]string{"dn"},
|
||||||
@@ -167,7 +176,7 @@ func (ldap *LdapService) GetUserGroups(userDN string) ([]string, error) {
|
|||||||
escapedUserDN := ldapgo.EscapeFilter(userDN)
|
escapedUserDN := ldapgo.EscapeFilter(userDN)
|
||||||
|
|
||||||
searchRequest := ldapgo.NewSearchRequest(
|
searchRequest := ldapgo.NewSearchRequest(
|
||||||
ldap.config.LDAP.BaseDN,
|
ldap.config.BaseDN,
|
||||||
ldapgo.ScopeWholeSubtree, ldapgo.NeverDerefAliases, 0, 0, false,
|
ldapgo.ScopeWholeSubtree, ldapgo.NeverDerefAliases, 0, 0, false,
|
||||||
fmt.Sprintf("(&(objectclass=groupOfUniqueNames)(uniquemember=%s))", escapedUserDN),
|
fmt.Sprintf("(&(objectclass=groupOfUniqueNames)(uniquemember=%s))", escapedUserDN),
|
||||||
[]string{"dn"},
|
[]string{"dn"},
|
||||||
@@ -215,7 +224,7 @@ func (ldap *LdapService) BindService(rebind bool) error {
|
|||||||
if ldap.cert != nil {
|
if ldap.cert != nil {
|
||||||
return ldap.conn.ExternalBind()
|
return ldap.conn.ExternalBind()
|
||||||
}
|
}
|
||||||
return ldap.conn.Bind(ldap.config.LDAP.BindDN, ldap.config.LDAP.BindPassword)
|
return ldap.conn.Bind(ldap.config.BindDN, ldap.config.BindPassword)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ldap *LdapService) Bind(userDN string, password string) error {
|
func (ldap *LdapService) Bind(userDN string, password string) error {
|
||||||
@@ -229,7 +238,7 @@ func (ldap *LdapService) Bind(userDN string, password string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (ldap *LdapService) heartbeat() error {
|
func (ldap *LdapService) heartbeat() error {
|
||||||
ldap.log.App.Debug().Msg("Performing LDAP connection heartbeat")
|
tlog.App.Debug().Msg("Performing LDAP connection heartbeat")
|
||||||
|
|
||||||
searchRequest := ldapgo.NewSearchRequest(
|
searchRequest := ldapgo.NewSearchRequest(
|
||||||
"",
|
"",
|
||||||
@@ -251,7 +260,7 @@ func (ldap *LdapService) heartbeat() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (ldap *LdapService) reconnect() error {
|
func (ldap *LdapService) reconnect() error {
|
||||||
ldap.log.App.Info().Msg("Attempting to reconnect to LDAP server")
|
tlog.App.Info().Msg("Reconnecting to LDAP server")
|
||||||
|
|
||||||
exp := backoff.NewExponentialBackOff()
|
exp := backoff.NewExponentialBackOff()
|
||||||
exp.InitialInterval = 500 * time.Millisecond
|
exp.InitialInterval = 500 * time.Millisecond
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"github.com/steveiliop56/tinyauth/internal/config"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
|
||||||
|
|
||||||
"slices"
|
|
||||||
|
|
||||||
|
"golang.org/x/exp/slices"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,43 +14,37 @@ type OAuthServiceImpl interface {
|
|||||||
NewRandom() string
|
NewRandom() string
|
||||||
GetAuthURL(state string, verifier string) string
|
GetAuthURL(state string, verifier string) string
|
||||||
GetToken(code string, verifier string) (*oauth2.Token, error)
|
GetToken(code string, verifier string) (*oauth2.Token, error)
|
||||||
GetUserinfo(token *oauth2.Token) (*model.Claims, error)
|
GetUserinfo(token *oauth2.Token) (config.Claims, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type OAuthBrokerService struct {
|
type OAuthBrokerService struct {
|
||||||
log *logger.Logger
|
|
||||||
|
|
||||||
services map[string]OAuthServiceImpl
|
services map[string]OAuthServiceImpl
|
||||||
configs map[string]model.OAuthServiceConfig
|
configs map[string]config.OAuthServiceConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
var presets = map[string]func(config model.OAuthServiceConfig, ctx context.Context) *OAuthService{
|
var presets = map[string]func(config config.OAuthServiceConfig) *OAuthService{
|
||||||
"github": newGitHubOAuthService,
|
"github": newGitHubOAuthService,
|
||||||
"google": newGoogleOAuthService,
|
"google": newGoogleOAuthService,
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOAuthBrokerService(
|
func NewOAuthBrokerService(configs map[string]config.OAuthServiceConfig) *OAuthBrokerService {
|
||||||
log *logger.Logger,
|
return &OAuthBrokerService{
|
||||||
configs map[string]model.OAuthServiceConfig,
|
|
||||||
ctx context.Context,
|
|
||||||
) *OAuthBrokerService {
|
|
||||||
service := &OAuthBrokerService{
|
|
||||||
log: log,
|
|
||||||
services: make(map[string]OAuthServiceImpl),
|
services: make(map[string]OAuthServiceImpl),
|
||||||
configs: configs,
|
configs: configs,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for name, cfg := range configs {
|
func (broker *OAuthBrokerService) Init() error {
|
||||||
|
for name, cfg := range broker.configs {
|
||||||
if presetFunc, exists := presets[name]; exists {
|
if presetFunc, exists := presets[name]; exists {
|
||||||
service.services[name] = presetFunc(cfg, ctx)
|
broker.services[name] = presetFunc(cfg)
|
||||||
service.log.App.Debug().Str("service", name).Msg("Loaded OAuth service from preset")
|
tlog.App.Debug().Str("service", name).Msg("Loaded OAuth service from preset")
|
||||||
} else {
|
} else {
|
||||||
service.services[name] = NewOAuthService(cfg, name, ctx)
|
broker.services[name] = NewOAuthService(cfg, name)
|
||||||
service.log.App.Debug().Str("service", name).Msg("Loaded OAuth service from custom config")
|
tlog.App.Debug().Str("service", name).Msg("Loaded OAuth service from config")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
return service
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (broker *OAuthBrokerService) GetConfiguredServices() []string {
|
func (broker *OAuthBrokerService) GetConfiguredServices() []string {
|
||||||
|
|||||||
@@ -8,13 +8,12 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
"github.com/steveiliop56/tinyauth/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type GithubEmailResponse []struct {
|
type GithubEmailResponse []struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Primary bool `json:"primary"`
|
Primary bool `json:"primary"`
|
||||||
Verified bool `json:"verified"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type GithubUserInfoResponse struct {
|
type GithubUserInfoResponse struct {
|
||||||
@@ -23,33 +22,33 @@ type GithubUserInfoResponse struct {
|
|||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultExtractor(client *http.Client, url string) (*model.Claims, error) {
|
func defaultExtractor(client *http.Client, url string) (config.Claims, error) {
|
||||||
return simpleReq[model.Claims](client, url, nil)
|
return simpleReq[config.Claims](client, url, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func githubExtractor(client *http.Client, _ string) (*model.Claims, error) {
|
func githubExtractor(client *http.Client, url string) (config.Claims, error) {
|
||||||
var user model.Claims
|
var user config.Claims
|
||||||
|
|
||||||
userInfo, err := simpleReq[GithubUserInfoResponse](client, "https://api.github.com/user", map[string]string{
|
userInfo, err := simpleReq[GithubUserInfoResponse](client, "https://api.github.com/user", map[string]string{
|
||||||
"accept": "application/vnd.github+json",
|
"accept": "application/vnd.github+json",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return config.Claims{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
userEmails, err := simpleReq[GithubEmailResponse](client, "https://api.github.com/user/emails", map[string]string{
|
userEmails, err := simpleReq[GithubEmailResponse](client, "https://api.github.com/user/emails", map[string]string{
|
||||||
"accept": "application/vnd.github+json",
|
"accept": "application/vnd.github+json",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return config.Claims{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(*userEmails) == 0 {
|
if len(userEmails) == 0 {
|
||||||
return nil, errors.New("no emails found")
|
return user, errors.New("no emails found")
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, email := range *userEmails {
|
for _, email := range userEmails {
|
||||||
if email.Primary && email.Verified {
|
if email.Primary {
|
||||||
user.Email = email.Email
|
user.Email = email.Email
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -57,31 +56,22 @@ func githubExtractor(client *http.Client, _ string) (*model.Claims, error) {
|
|||||||
|
|
||||||
// Use first available email if no primary email was found
|
// Use first available email if no primary email was found
|
||||||
if user.Email == "" {
|
if user.Email == "" {
|
||||||
for _, email := range *userEmails {
|
user.Email = userEmails[0].Email
|
||||||
if email.Verified {
|
|
||||||
user.Email = email.Email
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if user.Email == "" {
|
|
||||||
return nil, errors.New("no verified email found")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
user.PreferredUsername = userInfo.Login
|
user.PreferredUsername = userInfo.Login
|
||||||
user.Name = userInfo.Name
|
user.Name = userInfo.Name
|
||||||
user.Sub = strconv.Itoa(userInfo.ID)
|
user.Sub = strconv.Itoa(userInfo.ID)
|
||||||
|
|
||||||
return &user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func simpleReq[T any](client *http.Client, url string, headers map[string]string) (*T, error) {
|
func simpleReq[T any](client *http.Client, url string, headers map[string]string) (T, error) {
|
||||||
var decodedRes T
|
var decodedRes T
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return decodedRes, err
|
||||||
}
|
}
|
||||||
|
|
||||||
for key, value := range headers {
|
for key, value := range headers {
|
||||||
@@ -90,23 +80,23 @@ func simpleReq[T any](client *http.Client, url string, headers map[string]string
|
|||||||
|
|
||||||
res, err := client.Do(req)
|
res, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return decodedRes, err
|
||||||
}
|
}
|
||||||
defer res.Body.Close()
|
defer res.Body.Close()
|
||||||
|
|
||||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||||
return nil, fmt.Errorf("request failed with status: %s", res.Status)
|
return decodedRes, fmt.Errorf("request failed with status: %s", res.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(res.Body)
|
body, err := io.ReadAll(res.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return decodedRes, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = json.Unmarshal(body, &decodedRes)
|
err = json.Unmarshal(body, &decodedRes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return decodedRes, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &decodedRes, nil
|
return decodedRes, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,23 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"github.com/steveiliop56/tinyauth/internal/config"
|
||||||
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
|
||||||
"golang.org/x/oauth2/endpoints"
|
"golang.org/x/oauth2/endpoints"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newGoogleOAuthService(config model.OAuthServiceConfig, ctx context.Context) *OAuthService {
|
func newGoogleOAuthService(config config.OAuthServiceConfig) *OAuthService {
|
||||||
scopes := []string{"openid", "email", "profile"}
|
scopes := []string{"openid", "email", "profile"}
|
||||||
config.Scopes = scopes
|
config.Scopes = scopes
|
||||||
config.AuthURL = endpoints.Google.AuthURL
|
config.AuthURL = endpoints.Google.AuthURL
|
||||||
config.TokenURL = endpoints.Google.TokenURL
|
config.TokenURL = endpoints.Google.TokenURL
|
||||||
config.UserinfoURL = "https://openidconnect.googleapis.com/v1/userinfo"
|
config.UserinfoURL = "https://openidconnect.googleapis.com/v1/userinfo"
|
||||||
return NewOAuthService(config, "google", ctx)
|
return NewOAuthService(config, "google")
|
||||||
}
|
}
|
||||||
|
|
||||||
func newGitHubOAuthService(config model.OAuthServiceConfig, ctx context.Context) *OAuthService {
|
func newGitHubOAuthService(config config.OAuthServiceConfig) *OAuthService {
|
||||||
scopes := []string{"read:user", "user:email"}
|
scopes := []string{"read:user", "user:email"}
|
||||||
config.Scopes = scopes
|
config.Scopes = scopes
|
||||||
config.AuthURL = endpoints.GitHub.AuthURL
|
config.AuthURL = endpoints.GitHub.AuthURL
|
||||||
config.TokenURL = endpoints.GitHub.TokenURL
|
config.TokenURL = endpoints.GitHub.TokenURL
|
||||||
return NewOAuthService(config, "github", ctx).WithUserinfoExtractor(githubExtractor)
|
return NewOAuthService(config, "github").WithUserinfoExtractor(githubExtractor)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,21 +6,21 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
"github.com/steveiliop56/tinyauth/internal/config"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserinfoExtractor func(client *http.Client, url string) (*model.Claims, error)
|
type UserinfoExtractor func(client *http.Client, url string) (config.Claims, error)
|
||||||
|
|
||||||
type OAuthService struct {
|
type OAuthService struct {
|
||||||
serviceCfg model.OAuthServiceConfig
|
serviceCfg config.OAuthServiceConfig
|
||||||
config *oauth2.Config
|
config *oauth2.Config
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
userinfoExtractor UserinfoExtractor
|
userinfoExtractor UserinfoExtractor
|
||||||
id string
|
id string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOAuthService(config model.OAuthServiceConfig, id string, ctx context.Context) *OAuthService {
|
func NewOAuthService(config config.OAuthServiceConfig, id string) *OAuthService {
|
||||||
httpClient := &http.Client{
|
httpClient := &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
Transport: &http.Transport{
|
Transport: &http.Transport{
|
||||||
@@ -29,7 +29,8 @@ func NewOAuthService(config model.OAuthServiceConfig, id string, ctx context.Con
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
vctx := context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
ctx := context.Background()
|
||||||
|
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
||||||
|
|
||||||
return &OAuthService{
|
return &OAuthService{
|
||||||
serviceCfg: config,
|
serviceCfg: config,
|
||||||
@@ -43,7 +44,7 @@ func NewOAuthService(config model.OAuthServiceConfig, id string, ctx context.Con
|
|||||||
TokenURL: config.TokenURL,
|
TokenURL: config.TokenURL,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ctx: vctx,
|
ctx: ctx,
|
||||||
userinfoExtractor: defaultExtractor,
|
userinfoExtractor: defaultExtractor,
|
||||||
id: id,
|
id: id,
|
||||||
}
|
}
|
||||||
@@ -77,7 +78,7 @@ func (s *OAuthService) GetToken(code string, verifier string) (*oauth2.Token, er
|
|||||||
return s.config.Exchange(s.ctx, code, oauth2.VerifierOption(verifier))
|
return s.config.Exchange(s.ctx, code, oauth2.VerifierOption(verifier))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OAuthService) GetUserinfo(token *oauth2.Token) (*model.Claims, error) {
|
func (s *OAuthService) GetUserinfo(token *oauth2.Token) (config.Claims, error) {
|
||||||
client := oauth2.NewClient(s.ctx, oauth2.StaticTokenSource(token))
|
client := oauth2.NewClient(s.ctx, oauth2.StaticTokenSource(token))
|
||||||
return s.userinfoExtractor(client, s.serviceCfg.UserinfoURL)
|
return s.userinfoExtractor(client, s.serviceCfg.UserinfoURL)
|
||||||
}
|
}
|
||||||
|
|||||||
+154
-233
@@ -16,21 +16,19 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"slices"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/go-jose/go-jose/v4"
|
"github.com/go-jose/go-jose/v4"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
"github.com/steveiliop56/tinyauth/internal/config"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
"github.com/steveiliop56/tinyauth/internal/repository"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
|
"golang.org/x/exp/slices"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
SupportedScopes = []string{"openid", "profile", "email", "phone", "address", "groups"}
|
SupportedScopes = []string{"openid", "profile", "email", "groups"}
|
||||||
SupportedResponseTypes = []string{"code"}
|
SupportedResponseTypes = []string{"code"}
|
||||||
SupportedGrantTypes = []string{"authorization_code", "refresh_token"}
|
SupportedGrantTypes = []string{"authorization_code", "refresh_token"}
|
||||||
)
|
)
|
||||||
@@ -50,17 +48,6 @@ type ClaimSet struct {
|
|||||||
Iat int64 `json:"iat"`
|
Iat int64 `json:"iat"`
|
||||||
Exp int64 `json:"exp"`
|
Exp int64 `json:"exp"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
GivenName string `json:"given_name,omitempty"`
|
|
||||||
FamilyName string `json:"family_name,omitempty"`
|
|
||||||
MiddleName string `json:"middle_name,omitempty"`
|
|
||||||
Nickname string `json:"nickname,omitempty"`
|
|
||||||
Profile string `json:"profile,omitempty"`
|
|
||||||
Picture string `json:"picture,omitempty"`
|
|
||||||
Website string `json:"website,omitempty"`
|
|
||||||
Gender string `json:"gender,omitempty"`
|
|
||||||
Birthdate string `json:"birthdate,omitempty"`
|
|
||||||
Zoneinfo string `json:"zoneinfo,omitempty"`
|
|
||||||
Locale string `json:"locale,omitempty"`
|
|
||||||
Email string `json:"email,omitempty"`
|
Email string `json:"email,omitempty"`
|
||||||
EmailVerified bool `json:"email_verified,omitempty"`
|
EmailVerified bool `json:"email_verified,omitempty"`
|
||||||
PreferredUsername string `json:"preferred_username,omitempty"`
|
PreferredUsername string `json:"preferred_username,omitempty"`
|
||||||
@@ -69,27 +56,13 @@ type ClaimSet struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserinfoResponse struct {
|
type UserinfoResponse struct {
|
||||||
Sub string `json:"sub"`
|
Sub string `json:"sub"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
GivenName string `json:"given_name,omitempty"`
|
Email string `json:"email,omitempty"`
|
||||||
FamilyName string `json:"family_name,omitempty"`
|
PreferredUsername string `json:"preferred_username,omitempty"`
|
||||||
MiddleName string `json:"middle_name,omitempty"`
|
Groups []string `json:"groups,omitempty"`
|
||||||
Nickname string `json:"nickname,omitempty"`
|
EmailVerified bool `json:"email_verified,omitempty"`
|
||||||
Profile string `json:"profile,omitempty"`
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
Picture string `json:"picture,omitempty"`
|
|
||||||
Website string `json:"website,omitempty"`
|
|
||||||
Gender string `json:"gender,omitempty"`
|
|
||||||
Birthdate string `json:"birthdate,omitempty"`
|
|
||||||
Zoneinfo string `json:"zoneinfo,omitempty"`
|
|
||||||
Locale string `json:"locale,omitempty"`
|
|
||||||
Email string `json:"email,omitempty"`
|
|
||||||
PreferredUsername string `json:"preferred_username,omitempty"`
|
|
||||||
Groups []string `json:"groups,omitempty"`
|
|
||||||
EmailVerified bool `json:"email_verified,omitempty"`
|
|
||||||
PhoneNumber string `json:"phone_number,omitempty"`
|
|
||||||
PhoneNumberVerified *bool `json:"phone_number_verified,omitempty"`
|
|
||||||
Address *model.AddressClaim `json:"address,omitempty"`
|
|
||||||
UpdatedAt int64 `json:"updated_at"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type TokenResponse struct {
|
type TokenResponse struct {
|
||||||
@@ -112,180 +85,179 @@ type AuthorizeRequest struct {
|
|||||||
CodeChallengeMethod string `json:"code_challenge_method"`
|
CodeChallengeMethod string `json:"code_challenge_method"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OIDCService struct {
|
type OIDCServiceConfig struct {
|
||||||
log *logger.Logger
|
Clients map[string]config.OIDCClientConfig
|
||||||
config model.Config
|
PrivateKeyPath string
|
||||||
runtime model.RuntimeConfig
|
PublicKeyPath string
|
||||||
queries *repository.Queries
|
Issuer string
|
||||||
context context.Context
|
SessionExpiry int
|
||||||
|
|
||||||
clients map[string]model.OIDCClientConfig
|
|
||||||
privateKey *rsa.PrivateKey
|
|
||||||
publicKey crypto.PublicKey
|
|
||||||
issuer string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOIDCService(
|
type OIDCService struct {
|
||||||
log *logger.Logger,
|
config OIDCServiceConfig
|
||||||
config model.Config,
|
queries *repository.Queries
|
||||||
runtime model.RuntimeConfig,
|
clients map[string]config.OIDCClientConfig
|
||||||
queries *repository.Queries,
|
privateKey *rsa.PrivateKey
|
||||||
ctx context.Context,
|
publicKey crypto.PublicKey
|
||||||
wg *sync.WaitGroup) (*OIDCService, error) {
|
issuer string
|
||||||
|
isConfigured bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOIDCService(config OIDCServiceConfig, queries *repository.Queries) *OIDCService {
|
||||||
|
return &OIDCService{
|
||||||
|
config: config,
|
||||||
|
queries: queries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *OIDCService) IsConfigured() bool {
|
||||||
|
return service.isConfigured
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *OIDCService) Init() error {
|
||||||
// If not configured, skip init
|
// If not configured, skip init
|
||||||
if len(runtime.OIDCClients) == 0 {
|
if len(service.config.Clients) == 0 {
|
||||||
return nil, nil
|
service.isConfigured = false
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
service.isConfigured = true
|
||||||
|
|
||||||
// Ensure issuer is https
|
// Ensure issuer is https
|
||||||
uissuer, err := url.Parse(runtime.AppURL)
|
uissuer, err := url.Parse(service.config.Issuer)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse app url: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if uissuer.Scheme != "https" {
|
if uissuer.Scheme != "https" {
|
||||||
return nil, errors.New("issuer must be https")
|
return errors.New("issuer must be https")
|
||||||
}
|
}
|
||||||
|
|
||||||
issuer := fmt.Sprintf("%s://%s", uissuer.Scheme, uissuer.Host)
|
service.issuer = fmt.Sprintf("%s://%s", uissuer.Scheme, uissuer.Host)
|
||||||
|
|
||||||
// Create/load private and public keys
|
// Create/load private and public keys
|
||||||
if strings.TrimSpace(config.OIDC.PrivateKeyPath) == "" ||
|
if strings.TrimSpace(service.config.PrivateKeyPath) == "" ||
|
||||||
strings.TrimSpace(config.OIDC.PublicKeyPath) == "" {
|
strings.TrimSpace(service.config.PublicKeyPath) == "" {
|
||||||
return nil, errors.New("private key path and public key path are required")
|
return errors.New("private key path and public key path are required")
|
||||||
}
|
}
|
||||||
|
|
||||||
var privateKey *rsa.PrivateKey
|
var privateKey *rsa.PrivateKey
|
||||||
|
|
||||||
fprivateKey, err := os.ReadFile(config.OIDC.PrivateKeyPath)
|
fprivateKey, err := os.ReadFile(service.config.PrivateKeyPath)
|
||||||
|
|
||||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
privateKey, err = rsa.GenerateKey(rand.Reader, 2048)
|
privateKey, err = rsa.GenerateKey(rand.Reader, 2048)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to generate private key: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
der := x509.MarshalPKCS1PrivateKey(privateKey)
|
der := x509.MarshalPKCS1PrivateKey(privateKey)
|
||||||
if der == nil {
|
if der == nil {
|
||||||
return nil, errors.New("failed to marshal private key")
|
return errors.New("failed to marshal private key")
|
||||||
}
|
}
|
||||||
encoded := pem.EncodeToMemory(&pem.Block{
|
encoded := pem.EncodeToMemory(&pem.Block{
|
||||||
Type: "RSA PRIVATE KEY",
|
Type: "RSA PRIVATE KEY",
|
||||||
Bytes: der,
|
Bytes: der,
|
||||||
})
|
})
|
||||||
log.App.Trace().Str("type", "RSA PRIVATE KEY").Msg("Generated private RSA key")
|
tlog.App.Trace().Str("type", "RSA PRIVATE KEY").Msg("Generated private RSA key")
|
||||||
err = os.WriteFile(config.OIDC.PrivateKeyPath, encoded, 0600)
|
err = os.WriteFile(service.config.PrivateKeyPath, encoded, 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to write private key to file: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
service.privateKey = privateKey
|
||||||
} else {
|
} else {
|
||||||
block, _ := pem.Decode(fprivateKey)
|
block, _ := pem.Decode(fprivateKey)
|
||||||
if block == nil {
|
if block == nil {
|
||||||
return nil, errors.New("failed to decode private key")
|
return errors.New("failed to decode private key")
|
||||||
}
|
}
|
||||||
log.App.Trace().Str("type", block.Type).Msg("Loaded private key")
|
tlog.App.Trace().Str("type", block.Type).Msg("Loaded private key")
|
||||||
privateKey, err = x509.ParsePKCS1PrivateKey(block.Bytes)
|
privateKey, err = x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse private key: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
service.privateKey = privateKey
|
||||||
}
|
}
|
||||||
|
|
||||||
var publicKey crypto.PublicKey
|
fpublicKey, err := os.ReadFile(service.config.PublicKeyPath)
|
||||||
|
|
||||||
fpublicKey, err := os.ReadFile(config.OIDC.PublicKeyPath)
|
|
||||||
|
|
||||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
return nil, fmt.Errorf("failed to read public key: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
publicKey = privateKey.Public()
|
publicKey := service.privateKey.Public()
|
||||||
der := x509.MarshalPKCS1PublicKey(publicKey.(*rsa.PublicKey))
|
der := x509.MarshalPKCS1PublicKey(publicKey.(*rsa.PublicKey))
|
||||||
if der == nil {
|
if der == nil {
|
||||||
return nil, errors.New("failed to marshal public key")
|
return errors.New("failed to marshal public key")
|
||||||
}
|
}
|
||||||
encoded := pem.EncodeToMemory(&pem.Block{
|
encoded := pem.EncodeToMemory(&pem.Block{
|
||||||
Type: "RSA PUBLIC KEY",
|
Type: "RSA PUBLIC KEY",
|
||||||
Bytes: der,
|
Bytes: der,
|
||||||
})
|
})
|
||||||
log.App.Trace().Str("type", "RSA PUBLIC KEY").Msg("Generated public RSA key")
|
tlog.App.Trace().Str("type", "RSA PUBLIC KEY").Msg("Generated public RSA key")
|
||||||
err = os.WriteFile(config.OIDC.PublicKeyPath, encoded, 0644)
|
err = os.WriteFile(service.config.PublicKeyPath, encoded, 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
|
service.publicKey = publicKey
|
||||||
} else {
|
} else {
|
||||||
block, _ := pem.Decode(fpublicKey)
|
block, _ := pem.Decode(fpublicKey)
|
||||||
if block == nil {
|
if block == nil {
|
||||||
return nil, errors.New("failed to decode public key")
|
return errors.New("failed to decode public key")
|
||||||
}
|
}
|
||||||
log.App.Trace().Str("type", block.Type).Msg("Loaded public key")
|
tlog.App.Trace().Str("type", block.Type).Msg("Loaded public key")
|
||||||
switch block.Type {
|
switch block.Type {
|
||||||
case "RSA PUBLIC KEY":
|
case "RSA PUBLIC KEY":
|
||||||
publicKey, err = x509.ParsePKCS1PublicKey(block.Bytes)
|
publicKey, err := x509.ParsePKCS1PublicKey(block.Bytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse public key: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
service.publicKey = publicKey
|
||||||
case "PUBLIC KEY":
|
case "PUBLIC KEY":
|
||||||
publicKey, err = x509.ParsePKIXPublicKey(block.Bytes)
|
publicKey, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse public key: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
service.publicKey = publicKey.(crypto.PublicKey)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported public key type: %s", block.Type)
|
return fmt.Errorf("unsupported public key type: %s", block.Type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
service.clients = make(map[string]config.OIDCClientConfig)
|
||||||
|
|
||||||
for id, client := range config.OIDC.Clients {
|
for id, client := range service.config.Clients {
|
||||||
client.ID = id
|
client.ID = id
|
||||||
if client.Name == "" {
|
if client.Name == "" {
|
||||||
client.Name = utils.Capitalize(client.ID)
|
client.Name = utils.Capitalize(client.ID)
|
||||||
}
|
}
|
||||||
clients[client.ClientID] = client
|
service.clients[client.ClientID] = client
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the client secrets from files if they exist
|
// Load the client secrets from files if they exist
|
||||||
for id, client := range clients {
|
for id, client := range service.clients {
|
||||||
secret := utils.GetSecret(client.ClientSecret, client.ClientSecretFile)
|
secret := utils.GetSecret(client.ClientSecret, client.ClientSecretFile)
|
||||||
if secret != "" {
|
if secret != "" {
|
||||||
client.ClientSecret = secret
|
client.ClientSecret = secret
|
||||||
}
|
}
|
||||||
client.ClientSecretFile = ""
|
client.ClientSecretFile = ""
|
||||||
clients[id] = client
|
service.clients[id] = client
|
||||||
log.App.Debug().Str("clientId", client.ClientID).Msg("Loaded OIDC client configuration")
|
tlog.App.Info().Str("id", client.ID).Msg("Registered OIDC client")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize the service
|
return nil
|
||||||
service := &OIDCService{
|
|
||||||
log: log,
|
|
||||||
config: config,
|
|
||||||
runtime: runtime,
|
|
||||||
queries: queries,
|
|
||||||
context: ctx,
|
|
||||||
|
|
||||||
clients: clients,
|
|
||||||
privateKey: privateKey,
|
|
||||||
publicKey: publicKey,
|
|
||||||
issuer: issuer,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start cleanup routine
|
|
||||||
wg.Go(service.cleanupRoutine)
|
|
||||||
|
|
||||||
return service, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *OIDCService) GetIssuer() string {
|
func (service *OIDCService) GetIssuer() string {
|
||||||
return service.issuer
|
return service.issuer
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *OIDCService) GetClient(id string) (model.OIDCClientConfig, bool) {
|
func (service *OIDCService) GetClient(id string) (config.OIDCClientConfig, bool) {
|
||||||
client, ok := service.clients[id]
|
client, ok := service.clients[id]
|
||||||
return client, ok
|
return client, ok
|
||||||
}
|
}
|
||||||
@@ -309,7 +281,7 @@ func (service *OIDCService) ValidateAuthorizeParams(req AuthorizeRequest) error
|
|||||||
return errors.New("invalid_scope")
|
return errors.New("invalid_scope")
|
||||||
}
|
}
|
||||||
if !slices.Contains(SupportedScopes, scope) {
|
if !slices.Contains(SupportedScopes, scope) {
|
||||||
service.log.App.Warn().Str("scope", scope).Msg("Requested unsupported scope")
|
tlog.App.Warn().Str("scope", scope).Msg("Unsupported OIDC scope, will be ignored")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,7 +331,7 @@ func (service *OIDCService) StoreCode(c *gin.Context, sub string, code string, r
|
|||||||
entry.CodeChallenge = req.CodeChallenge
|
entry.CodeChallenge = req.CodeChallenge
|
||||||
} else {
|
} else {
|
||||||
entry.CodeChallenge = service.hashAndEncodePKCE(req.CodeChallenge)
|
entry.CodeChallenge = service.hashAndEncodePKCE(req.CodeChallenge)
|
||||||
service.log.App.Warn().Msg("Using plain PKCE code challenge method is not recommended, consider switching to S256 for better security")
|
tlog.App.Warn().Msg("Received plain PKCE code challenge, it's recommended to use S256 for better security")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,42 +341,22 @@ func (service *OIDCService) StoreCode(c *gin.Context, sub string, code string, r
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *OIDCService) StoreUserinfo(c *gin.Context, sub string, userContext model.UserContext, req AuthorizeRequest) error {
|
func (service *OIDCService) StoreUserinfo(c *gin.Context, sub string, userContext config.UserContext, req AuthorizeRequest) error {
|
||||||
userInfoParams := repository.CreateOidcUserInfoParams{
|
userInfoParams := repository.CreateOidcUserInfoParams{
|
||||||
Sub: sub,
|
Sub: sub,
|
||||||
Name: userContext.GetName(),
|
Name: userContext.Name,
|
||||||
Email: userContext.GetEmail(),
|
Email: userContext.Email,
|
||||||
PreferredUsername: userContext.GetUsername(),
|
PreferredUsername: userContext.Username,
|
||||||
UpdatedAt: time.Now().Unix(),
|
UpdatedAt: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if userContext.IsLocal() {
|
|
||||||
addressJSON, err := json.Marshal(userContext.Local.Attributes.Address)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
userInfoParams.GivenName = userContext.Local.Attributes.GivenName
|
|
||||||
userInfoParams.FamilyName = userContext.Local.Attributes.FamilyName
|
|
||||||
userInfoParams.MiddleName = userContext.Local.Attributes.MiddleName
|
|
||||||
userInfoParams.Nickname = userContext.Local.Attributes.Nickname
|
|
||||||
userInfoParams.Profile = userContext.Local.Attributes.Profile
|
|
||||||
userInfoParams.Picture = userContext.Local.Attributes.Picture
|
|
||||||
userInfoParams.Website = userContext.Local.Attributes.Website
|
|
||||||
userInfoParams.Gender = userContext.Local.Attributes.Gender
|
|
||||||
userInfoParams.Birthdate = userContext.Local.Attributes.Birthdate
|
|
||||||
userInfoParams.Zoneinfo = userContext.Local.Attributes.Zoneinfo
|
|
||||||
userInfoParams.Locale = userContext.Local.Attributes.Locale
|
|
||||||
userInfoParams.PhoneNumber = userContext.Local.Attributes.PhoneNumber
|
|
||||||
userInfoParams.Address = string(addressJSON)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tinyauth will pass through the groups it got from an LDAP or an OIDC server
|
// Tinyauth will pass through the groups it got from an LDAP or an OIDC server
|
||||||
if userContext.IsLDAP() {
|
if userContext.Provider == "ldap" {
|
||||||
userInfoParams.Groups = strings.Join(userContext.LDAP.Groups, ",")
|
userInfoParams.Groups = userContext.LdapGroups
|
||||||
}
|
}
|
||||||
|
|
||||||
if userContext.IsOAuth() {
|
if userContext.OAuth && len(userContext.OAuthGroups) > 0 {
|
||||||
userInfoParams.Groups = strings.Join(userContext.OAuth.Groups, ",")
|
userInfoParams.Groups = userContext.OAuthGroups
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := service.queries.CreateOidcUserInfo(c, userInfoParams)
|
_, err := service.queries.CreateOidcUserInfo(c, userInfoParams)
|
||||||
@@ -449,9 +401,9 @@ func (service *OIDCService) GetCodeEntry(c *gin.Context, codeHash string, client
|
|||||||
return oidcCode, nil
|
return oidcCode, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *OIDCService) generateIDToken(client model.OIDCClientConfig, user repository.OidcUserinfo, scope string, nonce string) (string, error) {
|
func (service *OIDCService) generateIDToken(client config.OIDCClientConfig, user repository.OidcUserinfo, scope string, nonce string) (string, error) {
|
||||||
createdAt := time.Now().Unix()
|
createdAt := time.Now().Unix()
|
||||||
expiresAt := time.Now().Add(time.Duration(service.config.Auth.SessionExpiry) * time.Second).Unix()
|
expiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry) * time.Second).Unix()
|
||||||
|
|
||||||
hasher := sha256.New()
|
hasher := sha256.New()
|
||||||
|
|
||||||
@@ -515,7 +467,7 @@ func (service *OIDCService) generateIDToken(client model.OIDCClientConfig, user
|
|||||||
return token, nil
|
return token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *OIDCService) GenerateAccessToken(c *gin.Context, client model.OIDCClientConfig, codeEntry repository.OidcCode) (TokenResponse, error) {
|
func (service *OIDCService) GenerateAccessToken(c *gin.Context, client config.OIDCClientConfig, codeEntry repository.OidcCode) (TokenResponse, error) {
|
||||||
user, err := service.GetUserinfo(c, codeEntry.Sub)
|
user, err := service.GetUserinfo(c, codeEntry.Sub)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -531,16 +483,16 @@ func (service *OIDCService) GenerateAccessToken(c *gin.Context, client model.OID
|
|||||||
accessToken := utils.GenerateString(32)
|
accessToken := utils.GenerateString(32)
|
||||||
refreshToken := utils.GenerateString(32)
|
refreshToken := utils.GenerateString(32)
|
||||||
|
|
||||||
tokenExpiresAt := time.Now().Add(time.Duration(service.config.Auth.SessionExpiry) * time.Second).Unix()
|
tokenExpiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry) * time.Second).Unix()
|
||||||
|
|
||||||
// Refresh token lives double the time of an access token but can't be used to access userinfo
|
// Refresh token lives double the time of an access token but can't be used to access userinfo
|
||||||
refreshTokenExpiresAt := time.Now().Add(time.Duration(service.config.Auth.SessionExpiry*2) * time.Second).Unix()
|
refrshTokenExpiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry*2) * time.Second).Unix()
|
||||||
|
|
||||||
tokenResponse := TokenResponse{
|
tokenResponse := TokenResponse{
|
||||||
AccessToken: accessToken,
|
AccessToken: accessToken,
|
||||||
RefreshToken: refreshToken,
|
RefreshToken: refreshToken,
|
||||||
TokenType: "Bearer",
|
TokenType: "Bearer",
|
||||||
ExpiresIn: int64(service.config.Auth.SessionExpiry),
|
ExpiresIn: int64(service.config.SessionExpiry),
|
||||||
IDToken: idToken,
|
IDToken: idToken,
|
||||||
Scope: strings.ReplaceAll(codeEntry.Scope, ",", " "),
|
Scope: strings.ReplaceAll(codeEntry.Scope, ",", " "),
|
||||||
}
|
}
|
||||||
@@ -552,7 +504,7 @@ func (service *OIDCService) GenerateAccessToken(c *gin.Context, client model.OID
|
|||||||
ClientID: client.ClientID,
|
ClientID: client.ClientID,
|
||||||
Scope: codeEntry.Scope,
|
Scope: codeEntry.Scope,
|
||||||
TokenExpiresAt: tokenExpiresAt,
|
TokenExpiresAt: tokenExpiresAt,
|
||||||
RefreshTokenExpiresAt: refreshTokenExpiresAt,
|
RefreshTokenExpiresAt: refrshTokenExpiresAt,
|
||||||
Nonce: codeEntry.Nonce,
|
Nonce: codeEntry.Nonce,
|
||||||
CodeHash: codeEntry.CodeHash,
|
CodeHash: codeEntry.CodeHash,
|
||||||
})
|
})
|
||||||
@@ -568,7 +520,7 @@ func (service *OIDCService) RefreshAccessToken(c *gin.Context, refreshToken stri
|
|||||||
entry, err := service.queries.GetOidcTokenByRefreshToken(c, service.Hash(refreshToken))
|
entry, err := service.queries.GetOidcTokenByRefreshToken(c, service.Hash(refreshToken))
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if err == sql.ErrNoRows {
|
||||||
return TokenResponse{}, ErrTokenNotFound
|
return TokenResponse{}, ErrTokenNotFound
|
||||||
}
|
}
|
||||||
return TokenResponse{}, err
|
return TokenResponse{}, err
|
||||||
@@ -589,7 +541,7 @@ func (service *OIDCService) RefreshAccessToken(c *gin.Context, refreshToken stri
|
|||||||
return TokenResponse{}, err
|
return TokenResponse{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
idToken, err := service.generateIDToken(model.OIDCClientConfig{
|
idToken, err := service.generateIDToken(config.OIDCClientConfig{
|
||||||
ClientID: entry.ClientID,
|
ClientID: entry.ClientID,
|
||||||
}, user, entry.Scope, entry.Nonce)
|
}, user, entry.Scope, entry.Nonce)
|
||||||
|
|
||||||
@@ -600,14 +552,14 @@ func (service *OIDCService) RefreshAccessToken(c *gin.Context, refreshToken stri
|
|||||||
accessToken := utils.GenerateString(32)
|
accessToken := utils.GenerateString(32)
|
||||||
newRefreshToken := utils.GenerateString(32)
|
newRefreshToken := utils.GenerateString(32)
|
||||||
|
|
||||||
tokenExpiresAt := time.Now().Add(time.Duration(service.config.Auth.SessionExpiry) * time.Second).Unix()
|
tokenExpiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry) * time.Second).Unix()
|
||||||
refreshTokenExpiresAt := time.Now().Add(time.Duration(service.config.Auth.SessionExpiry*2) * time.Second).Unix()
|
refrshTokenExpiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry*2) * time.Second).Unix()
|
||||||
|
|
||||||
tokenResponse := TokenResponse{
|
tokenResponse := TokenResponse{
|
||||||
AccessToken: accessToken,
|
AccessToken: accessToken,
|
||||||
RefreshToken: newRefreshToken,
|
RefreshToken: newRefreshToken,
|
||||||
TokenType: "Bearer",
|
TokenType: "Bearer",
|
||||||
ExpiresIn: int64(service.config.Auth.SessionExpiry),
|
ExpiresIn: int64(service.config.SessionExpiry),
|
||||||
IDToken: idToken,
|
IDToken: idToken,
|
||||||
Scope: strings.ReplaceAll(entry.Scope, ",", " "),
|
Scope: strings.ReplaceAll(entry.Scope, ",", " "),
|
||||||
}
|
}
|
||||||
@@ -616,7 +568,7 @@ func (service *OIDCService) RefreshAccessToken(c *gin.Context, refreshToken stri
|
|||||||
AccessTokenHash: service.Hash(accessToken),
|
AccessTokenHash: service.Hash(accessToken),
|
||||||
RefreshTokenHash: service.Hash(newRefreshToken),
|
RefreshTokenHash: service.Hash(newRefreshToken),
|
||||||
TokenExpiresAt: tokenExpiresAt,
|
TokenExpiresAt: tokenExpiresAt,
|
||||||
RefreshTokenExpiresAt: refreshTokenExpiresAt,
|
RefreshTokenExpiresAt: refrshTokenExpiresAt,
|
||||||
RefreshTokenHash_2: service.Hash(refreshToken), // that's the selector, it's not stored in the db
|
RefreshTokenHash_2: service.Hash(refreshToken), // that's the selector, it's not stored in the db
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -647,7 +599,7 @@ func (service *OIDCService) GetAccessToken(c *gin.Context, tokenHash string) (re
|
|||||||
entry, err := service.queries.GetOidcToken(c, tokenHash)
|
entry, err := service.queries.GetOidcToken(c, tokenHash)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if err == sql.ErrNoRows {
|
||||||
return repository.OidcToken{}, ErrTokenNotFound
|
return repository.OidcToken{}, ErrTokenNotFound
|
||||||
}
|
}
|
||||||
return repository.OidcToken{}, err
|
return repository.OidcToken{}, err
|
||||||
@@ -685,22 +637,12 @@ func (service *OIDCService) CompileUserinfo(user repository.OidcUserinfo, scope
|
|||||||
if slices.Contains(scopes, "profile") {
|
if slices.Contains(scopes, "profile") {
|
||||||
userInfo.Name = user.Name
|
userInfo.Name = user.Name
|
||||||
userInfo.PreferredUsername = user.PreferredUsername
|
userInfo.PreferredUsername = user.PreferredUsername
|
||||||
userInfo.GivenName = user.GivenName
|
|
||||||
userInfo.FamilyName = user.FamilyName
|
|
||||||
userInfo.MiddleName = user.MiddleName
|
|
||||||
userInfo.Nickname = user.Nickname
|
|
||||||
userInfo.Profile = user.Profile
|
|
||||||
userInfo.Picture = user.Picture
|
|
||||||
userInfo.Website = user.Website
|
|
||||||
userInfo.Gender = user.Gender
|
|
||||||
userInfo.Birthdate = user.Birthdate
|
|
||||||
userInfo.Zoneinfo = user.Zoneinfo
|
|
||||||
userInfo.Locale = user.Locale
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if slices.Contains(scopes, "email") {
|
if slices.Contains(scopes, "email") {
|
||||||
userInfo.Email = user.Email
|
userInfo.Email = user.Email
|
||||||
userInfo.EmailVerified = user.Email != ""
|
// We can set this as a configuration option in the future but for now it's a good idea to assume it's true
|
||||||
|
userInfo.EmailVerified = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if slices.Contains(scopes, "groups") {
|
if slices.Contains(scopes, "groups") {
|
||||||
@@ -711,19 +653,6 @@ func (service *OIDCService) CompileUserinfo(user repository.OidcUserinfo, scope
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if slices.Contains(scopes, "phone") {
|
|
||||||
userInfo.PhoneNumber = user.PhoneNumber
|
|
||||||
verified := user.PhoneNumber != ""
|
|
||||||
userInfo.PhoneNumberVerified = &verified
|
|
||||||
}
|
|
||||||
|
|
||||||
if slices.Contains(scopes, "address") {
|
|
||||||
var addr model.AddressClaim
|
|
||||||
if err := json.Unmarshal([]byte(user.Address), &addr); err == nil {
|
|
||||||
userInfo.Address = &addr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return userInfo
|
return userInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -750,64 +679,56 @@ func (service *OIDCService) DeleteOldSession(ctx context.Context, sub string) er
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup routine - Resource heavy due to the linked tables
|
// Cleanup routine - Resource heavy due to the linked tables
|
||||||
func (service *OIDCService) cleanupRoutine() {
|
func (service *OIDCService) Cleanup() {
|
||||||
service.log.App.Debug().Msg("Starting OIDC cleanup routine")
|
// We need a context for the routine
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
ticker := time.NewTicker(time.Duration(30) * time.Minute)
|
ticker := time.NewTicker(time.Duration(30) * time.Minute)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for {
|
for range ticker.C {
|
||||||
select {
|
currentTime := time.Now().Unix()
|
||||||
case <-ticker.C:
|
|
||||||
service.log.App.Debug().Msg("Performing OIDC cleanup routine")
|
|
||||||
|
|
||||||
currentTime := time.Now().Unix()
|
// For the OIDC tokens, if they are expired we delete the userinfo and codes
|
||||||
|
expiredTokens, err := service.queries.DeleteExpiredOidcTokens(ctx, repository.DeleteExpiredOidcTokensParams{
|
||||||
|
TokenExpiresAt: currentTime,
|
||||||
|
RefreshTokenExpiresAt: currentTime,
|
||||||
|
})
|
||||||
|
|
||||||
// For the OIDC tokens, if they are expired we delete the userinfo and codes
|
if err != nil {
|
||||||
expiredTokens, err := service.queries.DeleteExpiredOidcTokens(service.context, repository.DeleteExpiredOidcTokensParams{
|
tlog.App.Warn().Err(err).Msg("Failed to delete expired tokens")
|
||||||
TokenExpiresAt: currentTime,
|
}
|
||||||
RefreshTokenExpiresAt: currentTime,
|
|
||||||
})
|
for _, expiredToken := range expiredTokens {
|
||||||
|
err := service.DeleteOldSession(ctx, expiredToken.Sub)
|
||||||
|
if err != nil {
|
||||||
|
tlog.App.Warn().Err(err).Msg("Failed to delete old session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For expired codes, we need to get the sub, check if tokens are expired and if they are remove everything
|
||||||
|
expiredCodes, err := service.queries.DeleteExpiredOidcCodes(ctx, currentTime)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
tlog.App.Warn().Err(err).Msg("Failed to delete expired codes")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, expiredCode := range expiredCodes {
|
||||||
|
token, err := service.queries.GetOidcTokenBySub(ctx, expiredCode.Sub)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
service.log.App.Warn().Err(err).Msg("Failed to delete expired tokens")
|
if err == sql.ErrNoRows {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tlog.App.Warn().Err(err).Msg("Failed to get OIDC token by sub")
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, expiredToken := range expiredTokens {
|
if token.TokenExpiresAt < currentTime && token.RefreshTokenExpiresAt < currentTime {
|
||||||
err := service.DeleteOldSession(service.context, expiredToken.Sub)
|
err := service.DeleteOldSession(ctx, expiredCode.Sub)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
service.log.App.Warn().Err(err).Msg("Failed to delete session for expired token")
|
tlog.App.Warn().Err(err).Msg("Failed to delete session")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For expired codes, we need to get the sub, check if tokens are expired and if they are remove everything
|
|
||||||
expiredCodes, err := service.queries.DeleteExpiredOidcCodes(service.context, currentTime)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
service.log.App.Warn().Err(err).Msg("Failed to delete expired codes")
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, expiredCode := range expiredCodes {
|
|
||||||
token, err := service.queries.GetOidcTokenBySub(service.context, expiredCode.Sub)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
service.log.App.Warn().Err(err).Msg("Failed to get token by sub for expired code")
|
|
||||||
}
|
|
||||||
|
|
||||||
if token.TokenExpiresAt < currentTime && token.RefreshTokenExpiresAt < currentTime {
|
|
||||||
err := service.DeleteOldSession(service.context, expiredCode.Sub)
|
|
||||||
if err != nil {
|
|
||||||
service.log.App.Warn().Err(err).Msg("Failed to delete session for expired code")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
service.log.App.Debug().Msg("Finished OIDC cleanup routine")
|
|
||||||
case <-service.context.Done():
|
|
||||||
service.log.App.Debug().Msg("Stopping OIDC cleanup routine")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,217 +0,0 @@
|
|||||||
package service_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newTestUser() repository.OidcUserinfo {
|
|
||||||
addr := model.AddressClaim{
|
|
||||||
Formatted: "123 Main St",
|
|
||||||
StreetAddress: "123 Main St",
|
|
||||||
Locality: "Springfield",
|
|
||||||
Region: "IL",
|
|
||||||
PostalCode: "62701",
|
|
||||||
Country: "US",
|
|
||||||
}
|
|
||||||
addrJSON, _ := json.Marshal(addr)
|
|
||||||
|
|
||||||
return repository.OidcUserinfo{
|
|
||||||
Sub: "test-sub",
|
|
||||||
Name: "Test User",
|
|
||||||
PreferredUsername: "testuser",
|
|
||||||
Email: "test@example.com",
|
|
||||||
Groups: "admins,users",
|
|
||||||
UpdatedAt: 1234567890,
|
|
||||||
GivenName: "Test",
|
|
||||||
FamilyName: "User",
|
|
||||||
MiddleName: "M",
|
|
||||||
Nickname: "testy",
|
|
||||||
Profile: "https://example.com/testuser",
|
|
||||||
Picture: "https://example.com/testuser.jpg",
|
|
||||||
Website: "https://testuser.example.com",
|
|
||||||
Gender: "male",
|
|
||||||
Birthdate: "1990-01-01",
|
|
||||||
Zoneinfo: "America/Chicago",
|
|
||||||
Locale: "en-US",
|
|
||||||
PhoneNumber: "+15555550100",
|
|
||||||
Address: string(addrJSON),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompileUserinfo(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
|
|
||||||
cfg := model.Config{
|
|
||||||
OIDC: model.OIDCConfig{
|
|
||||||
PrivateKeyPath: dir + "/key.pem",
|
|
||||||
PublicKeyPath: dir + "/key.pub",
|
|
||||||
},
|
|
||||||
Auth: model.AuthConfig{
|
|
||||||
SessionExpiry: 3600,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
runtime := model.RuntimeConfig{
|
|
||||||
AppURL: "https://tinyauth.example.com",
|
|
||||||
}
|
|
||||||
|
|
||||||
log := logger.NewLogger().WithTestConfig()
|
|
||||||
log.Init()
|
|
||||||
|
|
||||||
ctx := context.TODO()
|
|
||||||
wg := &sync.WaitGroup{}
|
|
||||||
|
|
||||||
svc, err := service.NewOIDCService(log, cfg, runtime, nil, ctx, wg)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
type testCase struct {
|
|
||||||
description string
|
|
||||||
mutate func(u *repository.OidcUserinfo)
|
|
||||||
scope string
|
|
||||||
run func(t *testing.T, info service.UserinfoResponse)
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []testCase{
|
|
||||||
{
|
|
||||||
description: "openid scope only returns sub and updated_at",
|
|
||||||
scope: "openid",
|
|
||||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
|
||||||
assert.Equal(t, "test-sub", info.Sub)
|
|
||||||
assert.Equal(t, int64(1234567890), info.UpdatedAt)
|
|
||||||
assert.Empty(t, info.Name)
|
|
||||||
assert.Empty(t, info.Email)
|
|
||||||
assert.Nil(t, info.Groups)
|
|
||||||
assert.Nil(t, info.PhoneNumberVerified)
|
|
||||||
assert.Nil(t, info.Address)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "profile scope returns all profile fields",
|
|
||||||
scope: "openid,profile",
|
|
||||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
|
||||||
assert.Equal(t, "Test User", info.Name)
|
|
||||||
assert.Equal(t, "testuser", info.PreferredUsername)
|
|
||||||
assert.Equal(t, "Test", info.GivenName)
|
|
||||||
assert.Equal(t, "User", info.FamilyName)
|
|
||||||
assert.Equal(t, "M", info.MiddleName)
|
|
||||||
assert.Equal(t, "testy", info.Nickname)
|
|
||||||
assert.Equal(t, "https://example.com/testuser", info.Profile)
|
|
||||||
assert.Equal(t, "https://example.com/testuser.jpg", info.Picture)
|
|
||||||
assert.Equal(t, "https://testuser.example.com", info.Website)
|
|
||||||
assert.Equal(t, "male", info.Gender)
|
|
||||||
assert.Equal(t, "1990-01-01", info.Birthdate)
|
|
||||||
assert.Equal(t, "America/Chicago", info.Zoneinfo)
|
|
||||||
assert.Equal(t, "en-US", info.Locale)
|
|
||||||
assert.Empty(t, info.Email)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "email scope sets email and email_verified true when email present",
|
|
||||||
scope: "openid,email",
|
|
||||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
|
||||||
assert.Equal(t, "test@example.com", info.Email)
|
|
||||||
assert.True(t, info.EmailVerified)
|
|
||||||
assert.Empty(t, info.Name)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "email scope sets email_verified false when email absent",
|
|
||||||
scope: "openid,email",
|
|
||||||
mutate: func(u *repository.OidcUserinfo) { u.Email = "" },
|
|
||||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
|
||||||
assert.Empty(t, info.Email)
|
|
||||||
assert.False(t, info.EmailVerified)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "phone scope sets phone_number_verified true when phone present",
|
|
||||||
scope: "openid,phone",
|
|
||||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
|
||||||
assert.Equal(t, "+15555550100", info.PhoneNumber)
|
|
||||||
require.NotNil(t, info.PhoneNumberVerified)
|
|
||||||
assert.True(t, *info.PhoneNumberVerified)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "phone scope sets phone_number_verified false when phone absent",
|
|
||||||
scope: "openid,phone",
|
|
||||||
mutate: func(u *repository.OidcUserinfo) { u.PhoneNumber = "" },
|
|
||||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
|
||||||
require.NotNil(t, info.PhoneNumberVerified)
|
|
||||||
assert.False(t, *info.PhoneNumberVerified)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "address scope returns parsed address",
|
|
||||||
scope: "openid,address",
|
|
||||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
|
||||||
require.NotNil(t, info.Address)
|
|
||||||
assert.Equal(t, "123 Main St", info.Address.Formatted)
|
|
||||||
assert.Equal(t, "123 Main St", info.Address.StreetAddress)
|
|
||||||
assert.Equal(t, "Springfield", info.Address.Locality)
|
|
||||||
assert.Equal(t, "IL", info.Address.Region)
|
|
||||||
assert.Equal(t, "62701", info.Address.PostalCode)
|
|
||||||
assert.Equal(t, "US", info.Address.Country)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "address scope with invalid JSON omits address",
|
|
||||||
scope: "openid,address",
|
|
||||||
mutate: func(u *repository.OidcUserinfo) { u.Address = "not-valid-json" },
|
|
||||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
|
||||||
assert.Nil(t, info.Address)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "groups scope returns split groups",
|
|
||||||
scope: "openid,groups",
|
|
||||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
|
||||||
assert.Equal(t, []string{"admins", "users"}, info.Groups)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "groups scope returns empty slice when no groups",
|
|
||||||
scope: "openid,groups",
|
|
||||||
mutate: func(u *repository.OidcUserinfo) { u.Groups = "" },
|
|
||||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
|
||||||
assert.Equal(t, []string{}, info.Groups)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "all scopes return all fields",
|
|
||||||
scope: "openid,profile,email,phone,address,groups",
|
|
||||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
|
||||||
assert.Equal(t, "Test User", info.Name)
|
|
||||||
assert.Equal(t, "test@example.com", info.Email)
|
|
||||||
assert.Equal(t, "+15555550100", info.PhoneNumber)
|
|
||||||
require.NotNil(t, info.PhoneNumberVerified)
|
|
||||||
assert.True(t, *info.PhoneNumberVerified)
|
|
||||||
require.NotNil(t, info.Address)
|
|
||||||
assert.Equal(t, "Springfield", info.Address.Locality)
|
|
||||||
assert.Equal(t, []string{"admins", "users"}, info.Groups)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.description, func(t *testing.T) {
|
|
||||||
user := newTestUser()
|
|
||||||
if test.mutate != nil {
|
|
||||||
test.mutate(&user)
|
|
||||||
}
|
|
||||||
info := svc.CompileUserinfo(user, test.scope)
|
|
||||||
test.run(t, info)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+22
-22
@@ -7,6 +7,10 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/config"
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/weppos/publicsuffix-go/publicsuffix"
|
"github.com/weppos/publicsuffix-go/publicsuffix"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,12 +24,13 @@ func GetCookieDomain(u string) (string, error) {
|
|||||||
host := parsed.Hostname()
|
host := parsed.Hostname()
|
||||||
|
|
||||||
if netIP := net.ParseIP(host); netIP != nil {
|
if netIP := net.ParseIP(host); netIP != nil {
|
||||||
return "", errors.New("ip addresses not allowed")
|
return "", errors.New("IP addresses not allowed")
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.Split(host, ".")
|
parts := strings.Split(host, ".")
|
||||||
|
|
||||||
if len(parts) == 2 {
|
if len(parts) == 2 {
|
||||||
|
tlog.App.Warn().Msgf("Running on the root domain, cookies will be set for .%v", host)
|
||||||
return host, nil
|
return host, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,27 +49,6 @@ func GetCookieDomain(u string) (string, error) {
|
|||||||
return domain, nil
|
return domain, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetStandaloneCookieDomain(u string) (string, error) {
|
|
||||||
parsed, err := url.Parse(u)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
host := parsed.Hostname()
|
|
||||||
|
|
||||||
if netIP := net.ParseIP(host); netIP != nil {
|
|
||||||
return "", errors.New("ip addresses not allowed")
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.Split(host, ".")
|
|
||||||
|
|
||||||
if len(parts) < 2 {
|
|
||||||
return "", errors.New("invalid app url")
|
|
||||||
}
|
|
||||||
|
|
||||||
return host, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ParseFileToLine(content string) string {
|
func ParseFileToLine(content string) string {
|
||||||
lines := strings.Split(content, "\n")
|
lines := strings.Split(content, "\n")
|
||||||
users := make([]string, 0)
|
users := make([]string, 0)
|
||||||
@@ -89,6 +73,22 @@ func Filter[T any](slice []T, test func(T) bool) (res []T) {
|
|||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetContext(c *gin.Context) (config.UserContext, error) {
|
||||||
|
userContextValue, exists := c.Get("context")
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return config.UserContext{}, errors.New("no user context in request")
|
||||||
|
}
|
||||||
|
|
||||||
|
userContext, ok := userContextValue.(*config.UserContext)
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return config.UserContext{}, errors.New("invalid user context in request")
|
||||||
|
}
|
||||||
|
|
||||||
|
return *userContext, nil
|
||||||
|
}
|
||||||
|
|
||||||
func IsRedirectSafe(redirectURL string, domain string) bool {
|
func IsRedirectSafe(redirectURL string, domain string) bool {
|
||||||
if redirectURL == "" {
|
if redirectURL == "" {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ package utils_test
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/steveiliop56/tinyauth/internal/config"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gotest.tools/v3/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGetRootDomain(t *testing.T) {
|
func TestGetRootDomain(t *testing.T) {
|
||||||
@@ -12,14 +15,14 @@ func TestGetRootDomain(t *testing.T) {
|
|||||||
domain := "http://sub.tinyauth.app"
|
domain := "http://sub.tinyauth.app"
|
||||||
expected := "tinyauth.app"
|
expected := "tinyauth.app"
|
||||||
result, err := utils.GetCookieDomain(domain)
|
result, err := utils.GetCookieDomain(domain)
|
||||||
assert.NoError(t, err)
|
assert.NilError(t, err)
|
||||||
assert.Equal(t, expected, result)
|
assert.Equal(t, expected, result)
|
||||||
|
|
||||||
// Domain with multiple subdomains
|
// Domain with multiple subdomains
|
||||||
domain = "http://b.c.tinyauth.app"
|
domain = "http://b.c.tinyauth.app"
|
||||||
expected = "c.tinyauth.app"
|
expected = "c.tinyauth.app"
|
||||||
result, err = utils.GetCookieDomain(domain)
|
result, err = utils.GetCookieDomain(domain)
|
||||||
assert.NoError(t, err)
|
assert.NilError(t, err)
|
||||||
assert.Equal(t, expected, result)
|
assert.Equal(t, expected, result)
|
||||||
|
|
||||||
// Invalid domain (only TLD)
|
// Invalid domain (only TLD)
|
||||||
@@ -30,7 +33,7 @@ func TestGetRootDomain(t *testing.T) {
|
|||||||
// IP address
|
// IP address
|
||||||
domain = "http://10.10.10.10"
|
domain = "http://10.10.10.10"
|
||||||
_, err = utils.GetCookieDomain(domain)
|
_, err = utils.GetCookieDomain(domain)
|
||||||
assert.ErrorContains(t, err, "ip addresses not allowed")
|
assert.ErrorContains(t, err, "IP addresses not allowed")
|
||||||
|
|
||||||
// Invalid URL
|
// Invalid URL
|
||||||
domain = "http://[::1]:namedport"
|
domain = "http://[::1]:namedport"
|
||||||
@@ -41,14 +44,14 @@ func TestGetRootDomain(t *testing.T) {
|
|||||||
domain = "https://sub.tinyauth.app/path"
|
domain = "https://sub.tinyauth.app/path"
|
||||||
expected = "tinyauth.app"
|
expected = "tinyauth.app"
|
||||||
result, err = utils.GetCookieDomain(domain)
|
result, err = utils.GetCookieDomain(domain)
|
||||||
assert.NoError(t, err)
|
assert.NilError(t, err)
|
||||||
assert.Equal(t, expected, result)
|
assert.Equal(t, expected, result)
|
||||||
|
|
||||||
// URL with port
|
// URL with port
|
||||||
domain = "http://sub.tinyauth.app:8080"
|
domain = "http://sub.tinyauth.app:8080"
|
||||||
expected = "tinyauth.app"
|
expected = "tinyauth.app"
|
||||||
result, err = utils.GetCookieDomain(domain)
|
result, err = utils.GetCookieDomain(domain)
|
||||||
assert.NoError(t, err)
|
assert.NilError(t, err)
|
||||||
assert.Equal(t, expected, result)
|
assert.Equal(t, expected, result)
|
||||||
|
|
||||||
// Domain managed by ICANN
|
// Domain managed by ICANN
|
||||||
@@ -95,35 +98,57 @@ func TestFilter(t *testing.T) {
|
|||||||
testFunc := func(n int) bool { return n%2 == 0 }
|
testFunc := func(n int) bool { return n%2 == 0 }
|
||||||
expected := []int{2, 4}
|
expected := []int{2, 4}
|
||||||
result := utils.Filter(slice, testFunc)
|
result := utils.Filter(slice, testFunc)
|
||||||
assert.Equal(t, expected, result)
|
assert.DeepEqual(t, expected, result)
|
||||||
|
|
||||||
// Case with no matches
|
// Case with no matches
|
||||||
slice = []int{1, 3, 5}
|
slice = []int{1, 3, 5}
|
||||||
testFunc = func(n int) bool { return n%2 == 0 }
|
testFunc = func(n int) bool { return n%2 == 0 }
|
||||||
expected = []int{}
|
expected = []int{}
|
||||||
result = utils.Filter(slice, testFunc)
|
result = utils.Filter(slice, testFunc)
|
||||||
assert.Equal(t, expected, result)
|
assert.DeepEqual(t, expected, result)
|
||||||
|
|
||||||
// Case with all matches
|
// Case with all matches
|
||||||
slice = []int{2, 4, 6}
|
slice = []int{2, 4, 6}
|
||||||
testFunc = func(n int) bool { return n%2 == 0 }
|
testFunc = func(n int) bool { return n%2 == 0 }
|
||||||
expected = []int{2, 4, 6}
|
expected = []int{2, 4, 6}
|
||||||
result = utils.Filter(slice, testFunc)
|
result = utils.Filter(slice, testFunc)
|
||||||
assert.Equal(t, expected, result)
|
assert.DeepEqual(t, expected, result)
|
||||||
|
|
||||||
// Case with empty slice
|
// Case with empty slice
|
||||||
slice = []int{}
|
slice = []int{}
|
||||||
testFunc = func(n int) bool { return n%2 == 0 }
|
testFunc = func(n int) bool { return n%2 == 0 }
|
||||||
expected = []int{}
|
expected = []int{}
|
||||||
result = utils.Filter(slice, testFunc)
|
result = utils.Filter(slice, testFunc)
|
||||||
assert.Equal(t, expected, result)
|
assert.DeepEqual(t, expected, result)
|
||||||
|
|
||||||
// Case with different type (string)
|
// Case with different type (string)
|
||||||
sliceStr := []string{"apple", "banana", "cherry"}
|
sliceStr := []string{"apple", "banana", "cherry"}
|
||||||
testFuncStr := func(s string) bool { return len(s) > 5 }
|
testFuncStr := func(s string) bool { return len(s) > 5 }
|
||||||
expectedStr := []string{"banana", "cherry"}
|
expectedStr := []string{"banana", "cherry"}
|
||||||
resultStr := utils.Filter(sliceStr, testFuncStr)
|
resultStr := utils.Filter(sliceStr, testFuncStr)
|
||||||
assert.Equal(t, expectedStr, resultStr)
|
assert.DeepEqual(t, expectedStr, resultStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetContext(t *testing.T) {
|
||||||
|
// Setup
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
c, _ := gin.CreateTestContext(nil)
|
||||||
|
|
||||||
|
// Normal case
|
||||||
|
c.Set("context", &config.UserContext{Username: "testuser"})
|
||||||
|
result, err := utils.GetContext(c)
|
||||||
|
assert.NilError(t, err)
|
||||||
|
assert.Equal(t, "testuser", result.Username)
|
||||||
|
|
||||||
|
// Case with no context
|
||||||
|
c.Set("context", nil)
|
||||||
|
_, err = utils.GetContext(c)
|
||||||
|
assert.Error(t, err, "invalid user context in request")
|
||||||
|
|
||||||
|
// Case with invalid context type
|
||||||
|
c.Set("context", "invalid type")
|
||||||
|
_, err = utils.GetContext(c)
|
||||||
|
assert.Error(t, err, "invalid user context in request")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIsRedirectSafe(t *testing.T) {
|
func TestIsRedirectSafe(t *testing.T) {
|
||||||
@@ -133,95 +158,50 @@ func TestIsRedirectSafe(t *testing.T) {
|
|||||||
// Case with no subdomain
|
// Case with no subdomain
|
||||||
redirectURL := "http://example.com/welcome"
|
redirectURL := "http://example.com/welcome"
|
||||||
result := utils.IsRedirectSafe(redirectURL, domain)
|
result := utils.IsRedirectSafe(redirectURL, domain)
|
||||||
assert.True(t, result)
|
assert.Equal(t, true, result)
|
||||||
|
|
||||||
// Case with different domain
|
// Case with different domain
|
||||||
redirectURL = "http://malicious.com/phishing"
|
redirectURL = "http://malicious.com/phishing"
|
||||||
result = utils.IsRedirectSafe(redirectURL, domain)
|
result = utils.IsRedirectSafe(redirectURL, domain)
|
||||||
assert.False(t, result)
|
assert.Equal(t, false, result)
|
||||||
|
|
||||||
// Case with subdomain
|
// Case with subdomain
|
||||||
redirectURL = "http://sub.example.com/page"
|
redirectURL = "http://sub.example.com/page"
|
||||||
result = utils.IsRedirectSafe(redirectURL, domain)
|
result = utils.IsRedirectSafe(redirectURL, domain)
|
||||||
assert.True(t, result)
|
assert.Equal(t, true, result)
|
||||||
|
|
||||||
// Case with sub-subdomain
|
// Case with sub-subdomain
|
||||||
redirectURL = "http://a.b.example.com/home"
|
redirectURL = "http://a.b.example.com/home"
|
||||||
result = utils.IsRedirectSafe(redirectURL, domain)
|
result = utils.IsRedirectSafe(redirectURL, domain)
|
||||||
assert.True(t, result)
|
assert.Equal(t, true, result)
|
||||||
|
|
||||||
// Case with empty redirect URL
|
// Case with empty redirect URL
|
||||||
redirectURL = ""
|
redirectURL = ""
|
||||||
result = utils.IsRedirectSafe(redirectURL, domain)
|
result = utils.IsRedirectSafe(redirectURL, domain)
|
||||||
assert.False(t, result)
|
assert.Equal(t, false, result)
|
||||||
|
|
||||||
// Case with invalid URL
|
// Case with invalid URL
|
||||||
redirectURL = "http://[::1]:namedport"
|
redirectURL = "http://[::1]:namedport"
|
||||||
result = utils.IsRedirectSafe(redirectURL, domain)
|
result = utils.IsRedirectSafe(redirectURL, domain)
|
||||||
assert.False(t, result)
|
assert.Equal(t, false, result)
|
||||||
|
|
||||||
// Case with URL having port
|
// Case with URL having port
|
||||||
redirectURL = "http://sub.example.com:8080/page"
|
redirectURL = "http://sub.example.com:8080/page"
|
||||||
result = utils.IsRedirectSafe(redirectURL, domain)
|
result = utils.IsRedirectSafe(redirectURL, domain)
|
||||||
assert.True(t, result)
|
assert.Equal(t, true, result)
|
||||||
|
|
||||||
// Case with URL having different subdomain
|
// Case with URL having different subdomain
|
||||||
redirectURL = "http://another.example.com/page"
|
redirectURL = "http://another.example.com/page"
|
||||||
result = utils.IsRedirectSafe(redirectURL, domain)
|
result = utils.IsRedirectSafe(redirectURL, domain)
|
||||||
assert.True(t, result)
|
assert.Equal(t, true, result)
|
||||||
|
|
||||||
// Case with URL having different TLD
|
// Case with URL having different TLD
|
||||||
redirectURL = "http://example.org/page"
|
redirectURL = "http://example.org/page"
|
||||||
result = utils.IsRedirectSafe(redirectURL, domain)
|
result = utils.IsRedirectSafe(redirectURL, domain)
|
||||||
assert.False(t, result)
|
assert.Equal(t, false, result)
|
||||||
|
|
||||||
// Case with malicious domain
|
// Case with malicious domain
|
||||||
redirectURL = "https://malicious-example.com/yoyo"
|
redirectURL = "https://malicious-example.com/yoyo"
|
||||||
result = utils.IsRedirectSafe(redirectURL, domain)
|
result = utils.IsRedirectSafe(redirectURL, domain)
|
||||||
assert.False(t, result)
|
assert.Equal(t, false, result)
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetStandaloneCookieDomain(t *testing.T) {
|
|
||||||
// Normal case
|
|
||||||
domain := "http://tinyauth.app"
|
|
||||||
expected := "tinyauth.app"
|
|
||||||
result, err := utils.GetStandaloneCookieDomain(domain)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, expected, result)
|
|
||||||
|
|
||||||
// URL with subdomain (full hostname is returned, no subdomain stripping)
|
|
||||||
domain = "http://sub.tinyauth.app"
|
|
||||||
expected = "sub.tinyauth.app"
|
|
||||||
result, err = utils.GetStandaloneCookieDomain(domain)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, expected, result)
|
|
||||||
|
|
||||||
// URL with port (port should be stripped)
|
|
||||||
domain = "http://tinyauth.app:8080"
|
|
||||||
expected = "tinyauth.app"
|
|
||||||
result, err = utils.GetStandaloneCookieDomain(domain)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, expected, result)
|
|
||||||
|
|
||||||
// URL with path
|
|
||||||
domain = "https://tinyauth.app/some/path"
|
|
||||||
expected = "tinyauth.app"
|
|
||||||
result, err = utils.GetStandaloneCookieDomain(domain)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, expected, result)
|
|
||||||
|
|
||||||
// IP address
|
|
||||||
domain = "http://10.10.10.10"
|
|
||||||
_, err = utils.GetStandaloneCookieDomain(domain)
|
|
||||||
assert.ErrorContains(t, err, "ip addresses not allowed")
|
|
||||||
|
|
||||||
// Invalid domain (only TLD)
|
|
||||||
domain = "com"
|
|
||||||
_, err = utils.GetStandaloneCookieDomain(domain)
|
|
||||||
assert.ErrorContains(t, err, "invalid app url")
|
|
||||||
|
|
||||||
// Invalid URL
|
|
||||||
domain = "http://[::1]:namedport"
|
|
||||||
_, err = utils.GetStandaloneCookieDomain(domain)
|
|
||||||
assert.ErrorContains(t, err, "parse \"http://[::1]:namedport\": invalid port \":namedport\" after host")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,41 +3,42 @@ package decoders_test
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/steveiliop56/tinyauth/internal/config"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
"github.com/steveiliop56/tinyauth/internal/utils/decoders"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/decoders"
|
|
||||||
|
"gotest.tools/v3/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDecodeLabels(t *testing.T) {
|
func TestDecodeLabels(t *testing.T) {
|
||||||
// Variables
|
// Variables
|
||||||
expected := model.Apps{
|
expected := config.Apps{
|
||||||
Apps: map[string]model.App{
|
Apps: map[string]config.App{
|
||||||
"foo": {
|
"foo": {
|
||||||
Config: model.AppConfig{
|
Config: config.AppConfig{
|
||||||
Domain: "example.com",
|
Domain: "example.com",
|
||||||
},
|
},
|
||||||
Users: model.AppUsers{
|
Users: config.AppUsers{
|
||||||
Allow: "user1,user2",
|
Allow: "user1,user2",
|
||||||
Block: "user3",
|
Block: "user3",
|
||||||
},
|
},
|
||||||
OAuth: model.AppOAuth{
|
OAuth: config.AppOAuth{
|
||||||
Whitelist: "somebody@example.com",
|
Whitelist: "somebody@example.com",
|
||||||
Groups: "group3",
|
Groups: "group3",
|
||||||
},
|
},
|
||||||
IP: model.AppIP{
|
IP: config.AppIP{
|
||||||
Allow: []string{"10.71.0.1/24", "10.71.0.2"},
|
Allow: []string{"10.71.0.1/24", "10.71.0.2"},
|
||||||
Block: []string{"10.10.10.10", "10.0.0.0/24"},
|
Block: []string{"10.10.10.10", "10.0.0.0/24"},
|
||||||
Bypass: []string{"192.168.1.1"},
|
Bypass: []string{"192.168.1.1"},
|
||||||
},
|
},
|
||||||
Response: model.AppResponse{
|
Response: config.AppResponse{
|
||||||
Headers: []string{"X-Foo=Bar", "X-Baz=Qux"},
|
Headers: []string{"X-Foo=Bar", "X-Baz=Qux"},
|
||||||
BasicAuth: model.AppBasicAuth{
|
BasicAuth: config.AppBasicAuth{
|
||||||
Username: "admin",
|
Username: "admin",
|
||||||
Password: "password",
|
Password: "password",
|
||||||
PasswordFile: "/path/to/passwordfile",
|
PasswordFile: "/path/to/passwordfile",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Path: model.AppPath{
|
Path: config.AppPath{
|
||||||
Allow: "/public",
|
Allow: "/public",
|
||||||
Block: "/private",
|
Block: "/private",
|
||||||
},
|
},
|
||||||
@@ -62,7 +63,7 @@ func TestDecodeLabels(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Test
|
// Test
|
||||||
result, err := decoders.DecodeLabels[model.Apps](test, "apps")
|
result, err := decoders.DecodeLabels[config.Apps](test, "apps")
|
||||||
assert.NoError(t, err)
|
assert.NilError(t, err)
|
||||||
assert.Equal(t, expected, result)
|
assert.DeepEqual(t, expected, result)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,25 +4,24 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"gotest.tools/v3/assert"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestReadFile(t *testing.T) {
|
func TestReadFile(t *testing.T) {
|
||||||
// Setup
|
// Setup
|
||||||
file, err := os.Create("/tmp/tinyauth_test_file")
|
file, err := os.Create("/tmp/tinyauth_test_file")
|
||||||
require.NoError(t, err)
|
assert.NilError(t, err)
|
||||||
|
|
||||||
_, err = file.WriteString("file content\n")
|
_, err = file.WriteString("file content\n")
|
||||||
require.NoError(t, err)
|
assert.NilError(t, err)
|
||||||
|
|
||||||
err = file.Close()
|
err = file.Close()
|
||||||
require.NoError(t, err)
|
assert.NilError(t, err)
|
||||||
defer os.Remove("/tmp/tinyauth_test_file")
|
defer os.Remove("/tmp/tinyauth_test_file")
|
||||||
|
|
||||||
// Normal case
|
// Normal case
|
||||||
content, err := ReadFile("/tmp/tinyauth_test_file")
|
content, err := ReadFile("/tmp/tinyauth_test_file")
|
||||||
assert.NoError(t, err)
|
assert.NilError(t, err)
|
||||||
assert.Equal(t, "file content\n", content)
|
assert.Equal(t, "file content\n", content)
|
||||||
|
|
||||||
// Non-existing file
|
// Non-existing file
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ package utils_test
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
|
||||||
|
"gotest.tools/v3/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseHeaders(t *testing.T) {
|
func TestParseHeaders(t *testing.T) {
|
||||||
@@ -17,7 +18,7 @@ func TestParseHeaders(t *testing.T) {
|
|||||||
"X-Custom-Header": "Value",
|
"X-Custom-Header": "Value",
|
||||||
"Another-Header": "AnotherValue",
|
"Another-Header": "AnotherValue",
|
||||||
}
|
}
|
||||||
assert.Equal(t, expected, utils.ParseHeaders(headers))
|
assert.DeepEqual(t, expected, utils.ParseHeaders(headers))
|
||||||
|
|
||||||
// Case insensitivity and trimming
|
// Case insensitivity and trimming
|
||||||
headers = []string{
|
headers = []string{
|
||||||
@@ -28,7 +29,7 @@ func TestParseHeaders(t *testing.T) {
|
|||||||
"X-Custom-Header": "Value",
|
"X-Custom-Header": "Value",
|
||||||
"Another-Header": "AnotherValue",
|
"Another-Header": "AnotherValue",
|
||||||
}
|
}
|
||||||
assert.Equal(t, expected, utils.ParseHeaders(headers))
|
assert.DeepEqual(t, expected, utils.ParseHeaders(headers))
|
||||||
|
|
||||||
// Invalid headers (missing '=', empty key/value)
|
// Invalid headers (missing '=', empty key/value)
|
||||||
headers = []string{
|
headers = []string{
|
||||||
@@ -38,7 +39,7 @@ func TestParseHeaders(t *testing.T) {
|
|||||||
" = ",
|
" = ",
|
||||||
}
|
}
|
||||||
expected = map[string]string{}
|
expected = map[string]string{}
|
||||||
assert.Equal(t, expected, utils.ParseHeaders(headers))
|
assert.DeepEqual(t, expected, utils.ParseHeaders(headers))
|
||||||
|
|
||||||
// Headers with unsafe characters
|
// Headers with unsafe characters
|
||||||
headers = []string{
|
headers = []string{
|
||||||
@@ -51,7 +52,7 @@ func TestParseHeaders(t *testing.T) {
|
|||||||
"Another-Header": "AnotherValue",
|
"Another-Header": "AnotherValue",
|
||||||
"Good-Header": "GoodValue",
|
"Good-Header": "GoodValue",
|
||||||
}
|
}
|
||||||
assert.Equal(t, expected, utils.ParseHeaders(headers))
|
assert.DeepEqual(t, expected, utils.ParseHeaders(headers))
|
||||||
|
|
||||||
// Header with spaces in key (should be ignored)
|
// Header with spaces in key (should be ignored)
|
||||||
headers = []string{
|
headers = []string{
|
||||||
@@ -61,7 +62,7 @@ func TestParseHeaders(t *testing.T) {
|
|||||||
expected = map[string]string{
|
expected = map[string]string{
|
||||||
"Valid-Header": "ValidValue",
|
"Valid-Header": "ValidValue",
|
||||||
}
|
}
|
||||||
assert.Equal(t, expected, utils.ParseHeaders(headers))
|
assert.DeepEqual(t, expected, utils.ParseHeaders(headers))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSanitizeHeader(t *testing.T) {
|
func TestSanitizeHeader(t *testing.T) {
|
||||||
|
|||||||
@@ -4,20 +4,21 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/config"
|
||||||
|
|
||||||
"github.com/tinyauthapp/paerser/cli"
|
"github.com/tinyauthapp/paerser/cli"
|
||||||
"github.com/tinyauthapp/paerser/env"
|
"github.com/tinyauthapp/paerser/env"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type EnvLoader struct{}
|
type EnvLoader struct{}
|
||||||
|
|
||||||
func (e *EnvLoader) Load(_ []string, cmd *cli.Command) (bool, error) {
|
func (e *EnvLoader) Load(_ []string, cmd *cli.Command) (bool, error) {
|
||||||
vars := env.FindPrefixedEnvVars(os.Environ(), model.DefaultNamePrefix, cmd.Configuration)
|
vars := env.FindPrefixedEnvVars(os.Environ(), config.DefaultNamePrefix, cmd.Configuration)
|
||||||
if len(vars) == 0 {
|
if len(vars) == 0 {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := env.Decode(vars, model.DefaultNamePrefix, cmd.Configuration); err != nil {
|
if err := env.Decode(vars, config.DefaultNamePrefix, cmd.Configuration); err != nil {
|
||||||
return false, fmt.Errorf("failed to decode configuration from environment variables: %w", err)
|
return false, fmt.Errorf("failed to decode configuration from environment variables: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
package logger
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Logger struct {
|
|
||||||
HTTP zerolog.Logger
|
|
||||||
App zerolog.Logger
|
|
||||||
config model.LogConfig
|
|
||||||
base zerolog.Logger
|
|
||||||
audit zerolog.Logger
|
|
||||||
writer io.Writer
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewLogger() *Logger {
|
|
||||||
return &Logger{
|
|
||||||
writer: os.Stderr,
|
|
||||||
config: model.LogConfig{
|
|
||||||
Level: "error",
|
|
||||||
Json: true,
|
|
||||||
Streams: model.LogStreams{
|
|
||||||
HTTP: model.LogStreamConfig{
|
|
||||||
Enabled: true,
|
|
||||||
},
|
|
||||||
App: model.LogStreamConfig{
|
|
||||||
Enabled: true,
|
|
||||||
},
|
|
||||||
// No reason to enabled audit by default since it will be suppressed by the log level
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Logger) WithConfig(cfg model.LogConfig) *Logger {
|
|
||||||
l.config = cfg
|
|
||||||
return l
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Logger) WithSimpleConfig() *Logger {
|
|
||||||
l.config = model.LogConfig{
|
|
||||||
Level: "info",
|
|
||||||
Json: false,
|
|
||||||
Streams: model.LogStreams{
|
|
||||||
HTTP: model.LogStreamConfig{Enabled: true},
|
|
||||||
App: model.LogStreamConfig{Enabled: true},
|
|
||||||
Audit: model.LogStreamConfig{Enabled: false},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return l
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Logger) WithTestConfig() *Logger {
|
|
||||||
l.config = model.LogConfig{
|
|
||||||
Level: "trace",
|
|
||||||
Streams: model.LogStreams{
|
|
||||||
HTTP: model.LogStreamConfig{Enabled: true},
|
|
||||||
App: model.LogStreamConfig{Enabled: true},
|
|
||||||
Audit: model.LogStreamConfig{Enabled: true},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return l
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Logger) WithWriter(writer io.Writer) *Logger {
|
|
||||||
l.writer = writer
|
|
||||||
return l
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Logger) Init() {
|
|
||||||
base := log.With().
|
|
||||||
Timestamp().
|
|
||||||
Logger().
|
|
||||||
Level(l.parseLogLevel(l.config.Level)).Output(l.writer)
|
|
||||||
|
|
||||||
if !l.config.Json {
|
|
||||||
base = base.Output(zerolog.ConsoleWriter{
|
|
||||||
Out: l.writer,
|
|
||||||
TimeFormat: time.RFC3339,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if base.GetLevel() == zerolog.TraceLevel || base.GetLevel() == zerolog.DebugLevel {
|
|
||||||
base = base.With().Caller().Logger()
|
|
||||||
}
|
|
||||||
|
|
||||||
l.base = base
|
|
||||||
l.audit = l.createLogger("audit", l.config.Streams.Audit)
|
|
||||||
l.HTTP = l.createLogger("http", l.config.Streams.HTTP)
|
|
||||||
l.App = l.createLogger("app", l.config.Streams.App)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Logger) parseLogLevel(level string) zerolog.Level {
|
|
||||||
if level == "" {
|
|
||||||
return zerolog.InfoLevel
|
|
||||||
}
|
|
||||||
parsed, err := zerolog.ParseLevel(strings.ToLower(level))
|
|
||||||
if err != nil {
|
|
||||||
log.Warn().Err(err).Str("level", level).Msg("Invalid log level, defaulting to error")
|
|
||||||
parsed = zerolog.ErrorLevel
|
|
||||||
}
|
|
||||||
return parsed
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Logger) createLogger(component string, cfg model.LogStreamConfig) zerolog.Logger {
|
|
||||||
if !cfg.Enabled {
|
|
||||||
return zerolog.Nop()
|
|
||||||
}
|
|
||||||
sub := l.base.With().Str("stream", component).Logger()
|
|
||||||
if cfg.Level != "" {
|
|
||||||
sub = sub.Level(l.parseLogLevel(cfg.Level))
|
|
||||||
}
|
|
||||||
return sub
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Logger) AuditLoginSuccess(username, provider, ip string) {
|
|
||||||
l.audit.Info().
|
|
||||||
CallerSkipFrame(1).
|
|
||||||
Str("event", "login").
|
|
||||||
Str("result", "success").
|
|
||||||
Str("username", username).
|
|
||||||
Str("provider", provider).
|
|
||||||
Str("ip", ip).
|
|
||||||
Send()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Logger) AuditLoginFailure(username, provider, ip, reason string) {
|
|
||||||
l.audit.Warn().
|
|
||||||
CallerSkipFrame(1).
|
|
||||||
Str("event", "login").
|
|
||||||
Str("result", "failure").
|
|
||||||
Str("username", username).
|
|
||||||
Str("provider", provider).
|
|
||||||
Str("ip", ip).
|
|
||||||
Str("reason", reason).
|
|
||||||
Send()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Logger) AuditLogout(username, provider, ip string) {
|
|
||||||
l.audit.Info().
|
|
||||||
CallerSkipFrame(1).
|
|
||||||
Str("event", "logout").
|
|
||||||
Str("result", "success").
|
|
||||||
Str("username", username).
|
|
||||||
Str("provider", provider).
|
|
||||||
Str("ip", ip).
|
|
||||||
Send()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Used for testing
|
|
||||||
func (l *Logger) GetConfig() model.LogConfig {
|
|
||||||
return l.config
|
|
||||||
}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
package logger_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestLogger(t *testing.T) {
|
|
||||||
type testCase struct {
|
|
||||||
description string
|
|
||||||
run func(t *testing.T)
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []testCase{
|
|
||||||
{
|
|
||||||
description: "Should create a simple logger with the expected config",
|
|
||||||
run: func(t *testing.T) {
|
|
||||||
l := logger.NewLogger().WithSimpleConfig()
|
|
||||||
l.Init()
|
|
||||||
|
|
||||||
cfg := l.GetConfig()
|
|
||||||
|
|
||||||
assert.Equal(t, cfg, model.LogConfig{
|
|
||||||
Level: "info",
|
|
||||||
Json: false,
|
|
||||||
Streams: model.LogStreams{
|
|
||||||
HTTP: model.LogStreamConfig{Enabled: true},
|
|
||||||
App: model.LogStreamConfig{Enabled: true},
|
|
||||||
Audit: model.LogStreamConfig{Enabled: false},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Should create a test logger with the expected config",
|
|
||||||
run: func(t *testing.T) {
|
|
||||||
l := logger.NewLogger().WithTestConfig()
|
|
||||||
l.Init()
|
|
||||||
|
|
||||||
cfg := l.GetConfig()
|
|
||||||
|
|
||||||
assert.Equal(t, cfg, model.LogConfig{
|
|
||||||
Level: "trace",
|
|
||||||
Json: false,
|
|
||||||
Streams: model.LogStreams{
|
|
||||||
HTTP: model.LogStreamConfig{Enabled: true},
|
|
||||||
App: model.LogStreamConfig{Enabled: true},
|
|
||||||
Audit: model.LogStreamConfig{Enabled: true},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Should create a logger with a custom config",
|
|
||||||
run: func(t *testing.T) {
|
|
||||||
customCfg := model.LogConfig{
|
|
||||||
Level: "debug",
|
|
||||||
Json: true,
|
|
||||||
Streams: model.LogStreams{
|
|
||||||
HTTP: model.LogStreamConfig{Enabled: false},
|
|
||||||
App: model.LogStreamConfig{Enabled: true},
|
|
||||||
Audit: model.LogStreamConfig{Enabled: false},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
l := logger.NewLogger().WithConfig(customCfg)
|
|
||||||
l.Init()
|
|
||||||
|
|
||||||
cfg := l.GetConfig()
|
|
||||||
|
|
||||||
assert.Equal(t, cfg, customCfg)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Default logger should use error type and log json",
|
|
||||||
run: func(t *testing.T) {
|
|
||||||
buf := bytes.Buffer{}
|
|
||||||
|
|
||||||
l := logger.NewLogger().WithWriter(&buf)
|
|
||||||
l.Init()
|
|
||||||
|
|
||||||
cfg := l.GetConfig()
|
|
||||||
|
|
||||||
assert.Equal(t, cfg, model.LogConfig{
|
|
||||||
Level: "error",
|
|
||||||
Json: true,
|
|
||||||
Streams: model.LogStreams{
|
|
||||||
HTTP: model.LogStreamConfig{Enabled: true},
|
|
||||||
App: model.LogStreamConfig{Enabled: true},
|
|
||||||
Audit: model.LogStreamConfig{Enabled: false},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
l.App.Error().Msg("test")
|
|
||||||
|
|
||||||
var entry map[string]any
|
|
||||||
err := json.Unmarshal(buf.Bytes(), &entry)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, "test", entry["message"])
|
|
||||||
assert.Equal(t, "app", entry["stream"])
|
|
||||||
assert.Equal(t, "error", entry["level"])
|
|
||||||
assert.NotEmpty(t, entry["time"])
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Should default to error level if an invalid level is provided",
|
|
||||||
run: func(t *testing.T) {
|
|
||||||
buf := bytes.Buffer{}
|
|
||||||
|
|
||||||
customCfg := model.LogConfig{
|
|
||||||
Level: "invalid",
|
|
||||||
Json: false,
|
|
||||||
Streams: model.LogStreams{
|
|
||||||
HTTP: model.LogStreamConfig{Enabled: true},
|
|
||||||
App: model.LogStreamConfig{Enabled: true},
|
|
||||||
Audit: model.LogStreamConfig{Enabled: false},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
l := logger.NewLogger().WithConfig(customCfg).WithWriter(&buf)
|
|
||||||
l.Init()
|
|
||||||
|
|
||||||
assert.Equal(t, zerolog.ErrorLevel, l.App.GetLevel())
|
|
||||||
assert.Equal(t, zerolog.ErrorLevel, l.HTTP.GetLevel())
|
|
||||||
|
|
||||||
// should not get logged
|
|
||||||
l.AuditLoginFailure("test", "test", "test", "test")
|
|
||||||
|
|
||||||
assert.Empty(t, buf.String())
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Should use nop logger for disabled streams",
|
|
||||||
run: func(t *testing.T) {
|
|
||||||
buf := bytes.Buffer{}
|
|
||||||
|
|
||||||
customCfg := model.LogConfig{
|
|
||||||
Level: "info",
|
|
||||||
Json: false,
|
|
||||||
Streams: model.LogStreams{
|
|
||||||
HTTP: model.LogStreamConfig{Enabled: false},
|
|
||||||
App: model.LogStreamConfig{Enabled: true},
|
|
||||||
Audit: model.LogStreamConfig{Enabled: false},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
l := logger.NewLogger().WithConfig(customCfg).WithWriter(&buf)
|
|
||||||
l.Init()
|
|
||||||
|
|
||||||
assert.Equal(t, zerolog.Disabled, l.HTTP.GetLevel())
|
|
||||||
|
|
||||||
l.App.Info().Msg("test")
|
|
||||||
|
|
||||||
l.AuditLoginFailure("test_nop", "test_nop", "test_nop", "test_nop")
|
|
||||||
|
|
||||||
assert.NotEmpty(t, buf.String())
|
|
||||||
assert.NotContains(t, "test_nop", buf.String())
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.description, test.run)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -41,7 +41,7 @@ func ParseSecretFile(contents string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func EncodeBasicAuth(username string, password string) string {
|
func GetBasicAuth(username string, password string) string {
|
||||||
auth := username + ":" + password
|
auth := username + ":" + password
|
||||||
return base64.StdEncoding.EncodeToString([]byte(auth))
|
return base64.StdEncoding.EncodeToString([]byte(auth))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,21 +4,21 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
"gotest.tools/v3/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGetSecret(t *testing.T) {
|
func TestGetSecret(t *testing.T) {
|
||||||
// Setup
|
// Setup
|
||||||
file, err := os.Create("/tmp/tinyauth_test_secret")
|
file, err := os.Create("/tmp/tinyauth_test_secret")
|
||||||
require.NoError(t, err)
|
assert.NilError(t, err)
|
||||||
|
|
||||||
_, err = file.WriteString(" secret \n")
|
_, err = file.WriteString(" secret \n")
|
||||||
require.NoError(t, err)
|
assert.NilError(t, err)
|
||||||
|
|
||||||
err = file.Close()
|
err = file.Close()
|
||||||
require.NoError(t, err)
|
assert.NilError(t, err)
|
||||||
defer os.Remove("/tmp/tinyauth_test_secret")
|
defer os.Remove("/tmp/tinyauth_test_secret")
|
||||||
|
|
||||||
// Get from config
|
// Get from config
|
||||||
@@ -55,50 +55,50 @@ func TestParseSecretFile(t *testing.T) {
|
|||||||
assert.Equal(t, "", utils.ParseSecretFile(content))
|
assert.Equal(t, "", utils.ParseSecretFile(content))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEncodeBasicAuth(t *testing.T) {
|
func TestGetBasicAuth(t *testing.T) {
|
||||||
// Normal case
|
// Normal case
|
||||||
username := "user"
|
username := "user"
|
||||||
password := "pass"
|
password := "pass"
|
||||||
expected := "dXNlcjpwYXNz" // base64 of "user:pass"
|
expected := "dXNlcjpwYXNz" // base64 of "user:pass"
|
||||||
assert.Equal(t, expected, utils.EncodeBasicAuth(username, password))
|
assert.Equal(t, expected, utils.GetBasicAuth(username, password))
|
||||||
|
|
||||||
// Empty username
|
// Empty username
|
||||||
username = ""
|
username = ""
|
||||||
password = "pass"
|
password = "pass"
|
||||||
expected = "OnBhc3M=" // base64 of ":pass"
|
expected = "OnBhc3M=" // base64 of ":pass"
|
||||||
assert.Equal(t, expected, utils.EncodeBasicAuth(username, password))
|
assert.Equal(t, expected, utils.GetBasicAuth(username, password))
|
||||||
|
|
||||||
// Empty password
|
// Empty password
|
||||||
username = "user"
|
username = "user"
|
||||||
password = ""
|
password = ""
|
||||||
expected = "dXNlcjo=" // base64 of "user:"
|
expected = "dXNlcjo=" // base64 of "user:"
|
||||||
assert.Equal(t, expected, utils.EncodeBasicAuth(username, password))
|
assert.Equal(t, expected, utils.GetBasicAuth(username, password))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFilterIP(t *testing.T) {
|
func TestFilterIP(t *testing.T) {
|
||||||
// Exact match IPv4
|
// Exact match IPv4
|
||||||
ok, err := utils.FilterIP("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.NilError(t, err)
|
||||||
assert.Equal(t, true, ok)
|
assert.Equal(t, true, ok)
|
||||||
|
|
||||||
// Non-match IPv4
|
// Non-match IPv4
|
||||||
ok, err = utils.FilterIP("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.NilError(t, err)
|
||||||
assert.Equal(t, false, ok)
|
assert.Equal(t, false, ok)
|
||||||
|
|
||||||
// CIDR match IPv4
|
// CIDR match IPv4
|
||||||
ok, err = utils.FilterIP("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.NilError(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.FilterIP("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.NilError(t, err)
|
||||||
assert.Equal(t, true, ok)
|
assert.Equal(t, true, ok)
|
||||||
|
|
||||||
// CIDR non-match IPv4
|
// CIDR non-match IPv4
|
||||||
ok, err = utils.FilterIP("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.NilError(t, err)
|
||||||
assert.Equal(t, false, ok)
|
assert.Equal(t, false, ok)
|
||||||
|
|
||||||
// Invalid CIDR
|
// Invalid CIDR
|
||||||
@@ -145,5 +145,5 @@ func TestGenerateUUID(t *testing.T) {
|
|||||||
|
|
||||||
// Different output for different input
|
// Different output for different input
|
||||||
id3 := utils.GenerateUUID("differentstring")
|
id3 := utils.GenerateUUID("differentstring")
|
||||||
assert.NotEqual(t, id2, id3)
|
assert.Assert(t, id1 != id3)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,41 +28,3 @@ func CoalesceToString(value any) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseNonEmptyLines(contents string) []string {
|
|
||||||
lines := make([]string, 0)
|
|
||||||
|
|
||||||
for line := range strings.SplitSeq(contents, "\n") {
|
|
||||||
lineTrimmed := strings.TrimSpace(line)
|
|
||||||
if lineTrimmed == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lines = append(lines, lineTrimmed)
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetStringList(valuesCfg []string, valuesPath string) ([]string, error) {
|
|
||||||
values := make([]string, 0, len(valuesCfg))
|
|
||||||
|
|
||||||
for _, value := range valuesCfg {
|
|
||||||
valueTrimmed := strings.TrimSpace(value)
|
|
||||||
if valueTrimmed == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
values = append(values, valueTrimmed)
|
|
||||||
}
|
|
||||||
|
|
||||||
if valuesPath == "" {
|
|
||||||
return values, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
contents, err := ReadFile(valuesPath)
|
|
||||||
if err != nil {
|
|
||||||
return []string{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
values = append(values, ParseNonEmptyLines(contents)...)
|
|
||||||
return values, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
package utils_test
|
package utils_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
|
||||||
|
"gotest.tools/v3/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCapitalize(t *testing.T) {
|
func TestCapitalize(t *testing.T) {
|
||||||
@@ -57,33 +57,3 @@ func TestCompileUserEmail(t *testing.T) {
|
|||||||
// Test with invalid email
|
// Test with invalid email
|
||||||
assert.Equal(t, "user@example.com", utils.CompileUserEmail("user", "example.com"))
|
assert.Equal(t, "user@example.com", utils.CompileUserEmail("user", "example.com"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseNonEmptyLines(t *testing.T) {
|
|
||||||
lines := utils.ParseNonEmptyLines(" first@example.com \n\n second@example.com \n \n")
|
|
||||||
|
|
||||||
assert.Equal(t, []string{"first@example.com", "second@example.com"}, lines)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetStringList(t *testing.T) {
|
|
||||||
file, err := os.Create("/tmp/tinyauth_list_test_file")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
_, err = file.WriteString(" third@example.com \n\n fourth@example.com \n")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
err = file.Close()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
defer os.Remove("/tmp/tinyauth_list_test_file")
|
|
||||||
|
|
||||||
values, err := utils.GetStringList([]string{" first@example.com ", "", "second@example.com"}, "/tmp/tinyauth_list_test_file")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, []string{"first@example.com", "second@example.com", "third@example.com", "fourth@example.com"}, values)
|
|
||||||
|
|
||||||
values, err = utils.GetStringList(nil, "")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, []string{}, values)
|
|
||||||
|
|
||||||
values, err = utils.GetStringList(nil, "/tmp/non_existing_list_file")
|
|
||||||
assert.ErrorContains(t, err, "no such file or directory")
|
|
||||||
assert.Equal(t, []string{}, values)
|
|
||||||
}
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user