Compare commits
126 Commits
cc49ab1655
...
main
Author | SHA1 | Date | |
---|---|---|---|
e86f73ec02 | |||
bdc42beb27 | |||
72083fa0a4 | |||
b3ad13e55d | |||
0db54e0268 | |||
b3ef96a0ce | |||
a773f1f8b4 | |||
1a71f50914 | |||
d17e154e42 | |||
bad26775eb | |||
c3fd6637a5 | |||
20173ea140 | |||
41d439beab | |||
b36b6e18ca | |||
1765485027 | |||
c5fb5e674a | |||
03bf655051 | |||
f8589fce5d | |||
ac62158de9 | |||
44e1a18e9a | |||
e0f2c3219f | |||
d2d52dc041 | |||
d7d142152c | |||
5c321311cd | |||
ffc8a5f44d | |||
dbff94e7b3 | |||
0b1ef77689 | |||
b0005c6702 | |||
c2abf1a5ba | |||
ac50929e6e | |||
32785398ca | |||
cc497b6016 | |||
97ffcbdaa4 | |||
d09bf8ff02 | |||
c0814093e5 | |||
d48519741d | |||
213991126d | |||
c7e88606e3 | |||
a0d506fb76 | |||
0ec7743fca | |||
a8a0fa55b7 | |||
7321448ce7 | |||
6d5e0fc9a9 | |||
ef05d66787 | |||
b3296c45ad | |||
7fd163f957 | |||
0f0d50a684 | |||
68074e02bc | |||
8d38a86f86 | |||
e0c095c24d | |||
4c318b15cd | |||
5ea6bc4251 | |||
1cbe908489 | |||
53ee156e67 | |||
07a936acc7 | |||
f892f0da24 | |||
38955ee4e6 | |||
7fa7e87e88 | |||
f085f2e271 | |||
08add259a4 | |||
5b6142dfa6 | |||
dc41521a99 | |||
299e7eddc4 | |||
b4699e987c | |||
be9d4f2a1b | |||
db99236501 | |||
e33fb04c99 | |||
c3d4208e12 | |||
3f945fa329 | |||
93a5cd7c70 | |||
951de989af | |||
c5cf253a15 | |||
d70032e36d | |||
445ac50537 | |||
c13e564b01 | |||
5d3a77133d | |||
44592ebc08 | |||
1b941cb0c3 | |||
1cb520c2b6 | |||
b3fdd3bc18 | |||
9110db2f08 | |||
5972735102 | |||
14c69349cc | |||
3ceeab04e1 | |||
b7a67c208f | |||
cee885a84d | |||
4b496ea9bd | |||
570ae6ac8c | |||
f0d3a61e7b | |||
b09567620f | |||
2209846525 | |||
108ed61961 | |||
b73bfd590b | |||
19d56159ba | |||
b9ccf6adac | |||
9a0870dbbc | |||
70429f69a2 | |||
ad635008eb | |||
eacc8fdd89 | |||
d309fb3f57 | |||
f4fd993679 | |||
016879b53f | |||
70bba15cda | |||
57daf175ab | |||
0817a65272 | |||
13f9da1a67 | |||
83535acf1c | |||
441ce2daca | |||
f9848d2110 | |||
7f0511b0d4 | |||
a27f2ad593 | |||
715a984241 | |||
66e1756ade | |||
849403a137 | |||
8d15c9b8b2 | |||
87af1834cf | |||
357583f54d | |||
aa6de76ded | |||
ab3c2d1eb0 | |||
644cf2a358 | |||
5b1ed9925d | |||
4071a50a37 | |||
dd7c51efd8 | |||
8902f4d187 | |||
6666b20464 | |||
c5f288ba1e |
@ -5,6 +5,8 @@ GUARD_URI="http://localhost:3001"
|
||||
|
||||
GUARD_DB_URL="postgres://<user>:<user>@<host>:<port>/<db>?sslmode=disable"
|
||||
|
||||
GUARD_REDIS_URL="redis://guard:guard@localhost:6379/0"
|
||||
|
||||
GUARD_ADMIN_NAME="admin"
|
||||
GUARD_ADMIN_EMAIL="admin@test.net"
|
||||
GUARD_ADMIN_PASSWORD="secret"
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -27,6 +27,7 @@ go.work.sum
|
||||
|
||||
# env file
|
||||
.env
|
||||
.env.remote
|
||||
|
||||
# key files
|
||||
*.pem
|
||||
|
@ -26,6 +26,8 @@ RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/
|
||||
COPY --from=backend-builder /app/bin/hspguard .
|
||||
COPY --from=frontend-builder /app/dist ./dist
|
||||
|
||||
COPY redis.conf /config/redis.conf
|
||||
|
||||
# Optional: copy default .env file if used
|
||||
# COPY .env .env
|
||||
|
||||
|
373
LICENSE
Normal file
373
LICENSE
Normal file
@ -0,0 +1,373 @@
|
||||
Mozilla Public License Version 2.0
|
||||
==================================
|
||||
|
||||
1. Definitions
|
||||
--------------
|
||||
|
||||
1.1. "Contributor"
|
||||
means each individual or legal entity that creates, contributes to
|
||||
the creation of, or owns Covered Software.
|
||||
|
||||
1.2. "Contributor Version"
|
||||
means the combination of the Contributions of others (if any) used
|
||||
by a Contributor and that particular Contributor's Contribution.
|
||||
|
||||
1.3. "Contribution"
|
||||
means Covered Software of a particular Contributor.
|
||||
|
||||
1.4. "Covered Software"
|
||||
means Source Code Form to which the initial Contributor has attached
|
||||
the notice in Exhibit A, the Executable Form of such Source Code
|
||||
Form, and Modifications of such Source Code Form, in each case
|
||||
including portions thereof.
|
||||
|
||||
1.5. "Incompatible With Secondary Licenses"
|
||||
means
|
||||
|
||||
(a) that the initial Contributor has attached the notice described
|
||||
in Exhibit B to the Covered Software; or
|
||||
|
||||
(b) that the Covered Software was made available under the terms of
|
||||
version 1.1 or earlier of the License, but not also under the
|
||||
terms of a Secondary License.
|
||||
|
||||
1.6. "Executable Form"
|
||||
means any form of the work other than Source Code Form.
|
||||
|
||||
1.7. "Larger Work"
|
||||
means a work that combines Covered Software with other material, in
|
||||
a separate file or files, that is not Covered Software.
|
||||
|
||||
1.8. "License"
|
||||
means this document.
|
||||
|
||||
1.9. "Licensable"
|
||||
means having the right to grant, to the maximum extent possible,
|
||||
whether at the time of the initial grant or subsequently, any and
|
||||
all of the rights conveyed by this License.
|
||||
|
||||
1.10. "Modifications"
|
||||
means any of the following:
|
||||
|
||||
(a) any file in Source Code Form that results from an addition to,
|
||||
deletion from, or modification of the contents of Covered
|
||||
Software; or
|
||||
|
||||
(b) any new file in Source Code Form that contains any Covered
|
||||
Software.
|
||||
|
||||
1.11. "Patent Claims" of a Contributor
|
||||
means any patent claim(s), including without limitation, method,
|
||||
process, and apparatus claims, in any patent Licensable by such
|
||||
Contributor that would be infringed, but for the grant of the
|
||||
License, by the making, using, selling, offering for sale, having
|
||||
made, import, or transfer of either its Contributions or its
|
||||
Contributor Version.
|
||||
|
||||
1.12. "Secondary License"
|
||||
means either the GNU General Public License, Version 2.0, the GNU
|
||||
Lesser General Public License, Version 2.1, the GNU Affero General
|
||||
Public License, Version 3.0, or any later versions of those
|
||||
licenses.
|
||||
|
||||
1.13. "Source Code Form"
|
||||
means the form of the work preferred for making modifications.
|
||||
|
||||
1.14. "You" (or "Your")
|
||||
means an individual or a legal entity exercising rights under this
|
||||
License. For legal entities, "You" includes any entity that
|
||||
controls, is controlled by, or is under common control with You. For
|
||||
purposes of this definition, "control" means (a) the power, direct
|
||||
or indirect, to cause the direction or management of such entity,
|
||||
whether by contract or otherwise, or (b) ownership of more than
|
||||
fifty percent (50%) of the outstanding shares or beneficial
|
||||
ownership of such entity.
|
||||
|
||||
2. License Grants and Conditions
|
||||
--------------------------------
|
||||
|
||||
2.1. Grants
|
||||
|
||||
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||
non-exclusive license:
|
||||
|
||||
(a) under intellectual property rights (other than patent or trademark)
|
||||
Licensable by such Contributor to use, reproduce, make available,
|
||||
modify, display, perform, distribute, and otherwise exploit its
|
||||
Contributions, either on an unmodified basis, with Modifications, or
|
||||
as part of a Larger Work; and
|
||||
|
||||
(b) under Patent Claims of such Contributor to make, use, sell, offer
|
||||
for sale, have made, import, and otherwise transfer either its
|
||||
Contributions or its Contributor Version.
|
||||
|
||||
2.2. Effective Date
|
||||
|
||||
The licenses granted in Section 2.1 with respect to any Contribution
|
||||
become effective for each Contribution on the date the Contributor first
|
||||
distributes such Contribution.
|
||||
|
||||
2.3. Limitations on Grant Scope
|
||||
|
||||
The licenses granted in this Section 2 are the only rights granted under
|
||||
this License. No additional rights or licenses will be implied from the
|
||||
distribution or licensing of Covered Software under this License.
|
||||
Notwithstanding Section 2.1(b) above, no patent license is granted by a
|
||||
Contributor:
|
||||
|
||||
(a) for any code that a Contributor has removed from Covered Software;
|
||||
or
|
||||
|
||||
(b) for infringements caused by: (i) Your and any other third party's
|
||||
modifications of Covered Software, or (ii) the combination of its
|
||||
Contributions with other software (except as part of its Contributor
|
||||
Version); or
|
||||
|
||||
(c) under Patent Claims infringed by Covered Software in the absence of
|
||||
its Contributions.
|
||||
|
||||
This License does not grant any rights in the trademarks, service marks,
|
||||
or logos of any Contributor (except as may be necessary to comply with
|
||||
the notice requirements in Section 3.4).
|
||||
|
||||
2.4. Subsequent Licenses
|
||||
|
||||
No Contributor makes additional grants as a result of Your choice to
|
||||
distribute the Covered Software under a subsequent version of this
|
||||
License (see Section 10.2) or under the terms of a Secondary License (if
|
||||
permitted under the terms of Section 3.3).
|
||||
|
||||
2.5. Representation
|
||||
|
||||
Each Contributor represents that the Contributor believes its
|
||||
Contributions are its original creation(s) or it has sufficient rights
|
||||
to grant the rights to its Contributions conveyed by this License.
|
||||
|
||||
2.6. Fair Use
|
||||
|
||||
This License is not intended to limit any rights You have under
|
||||
applicable copyright doctrines of fair use, fair dealing, or other
|
||||
equivalents.
|
||||
|
||||
2.7. Conditions
|
||||
|
||||
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
|
||||
in Section 2.1.
|
||||
|
||||
3. Responsibilities
|
||||
-------------------
|
||||
|
||||
3.1. Distribution of Source Form
|
||||
|
||||
All distribution of Covered Software in Source Code Form, including any
|
||||
Modifications that You create or to which You contribute, must be under
|
||||
the terms of this License. You must inform recipients that the Source
|
||||
Code Form of the Covered Software is governed by the terms of this
|
||||
License, and how they can obtain a copy of this License. You may not
|
||||
attempt to alter or restrict the recipients' rights in the Source Code
|
||||
Form.
|
||||
|
||||
3.2. Distribution of Executable Form
|
||||
|
||||
If You distribute Covered Software in Executable Form then:
|
||||
|
||||
(a) such Covered Software must also be made available in Source Code
|
||||
Form, as described in Section 3.1, and You must inform recipients of
|
||||
the Executable Form how they can obtain a copy of such Source Code
|
||||
Form by reasonable means in a timely manner, at a charge no more
|
||||
than the cost of distribution to the recipient; and
|
||||
|
||||
(b) You may distribute such Executable Form under the terms of this
|
||||
License, or sublicense it under different terms, provided that the
|
||||
license for the Executable Form does not attempt to limit or alter
|
||||
the recipients' rights in the Source Code Form under this License.
|
||||
|
||||
3.3. Distribution of a Larger Work
|
||||
|
||||
You may create and distribute a Larger Work under terms of Your choice,
|
||||
provided that You also comply with the requirements of this License for
|
||||
the Covered Software. If the Larger Work is a combination of Covered
|
||||
Software with a work governed by one or more Secondary Licenses, and the
|
||||
Covered Software is not Incompatible With Secondary Licenses, this
|
||||
License permits You to additionally distribute such Covered Software
|
||||
under the terms of such Secondary License(s), so that the recipient of
|
||||
the Larger Work may, at their option, further distribute the Covered
|
||||
Software under the terms of either this License or such Secondary
|
||||
License(s).
|
||||
|
||||
3.4. Notices
|
||||
|
||||
You may not remove or alter the substance of any license notices
|
||||
(including copyright notices, patent notices, disclaimers of warranty,
|
||||
or limitations of liability) contained within the Source Code Form of
|
||||
the Covered Software, except that You may alter any license notices to
|
||||
the extent required to remedy known factual inaccuracies.
|
||||
|
||||
3.5. Application of Additional Terms
|
||||
|
||||
You may choose to offer, and to charge a fee for, warranty, support,
|
||||
indemnity or liability obligations to one or more recipients of Covered
|
||||
Software. However, You may do so only on Your own behalf, and not on
|
||||
behalf of any Contributor. You must make it absolutely clear that any
|
||||
such warranty, support, indemnity, or liability obligation is offered by
|
||||
You alone, and You hereby agree to indemnify every Contributor for any
|
||||
liability incurred by such Contributor as a result of warranty, support,
|
||||
indemnity or liability terms You offer. You may include additional
|
||||
disclaimers of warranty and limitations of liability specific to any
|
||||
jurisdiction.
|
||||
|
||||
4. Inability to Comply Due to Statute or Regulation
|
||||
---------------------------------------------------
|
||||
|
||||
If it is impossible for You to comply with any of the terms of this
|
||||
License with respect to some or all of the Covered Software due to
|
||||
statute, judicial order, or regulation then You must: (a) comply with
|
||||
the terms of this License to the maximum extent possible; and (b)
|
||||
describe the limitations and the code they affect. Such description must
|
||||
be placed in a text file included with all distributions of the Covered
|
||||
Software under this License. Except to the extent prohibited by statute
|
||||
or regulation, such description must be sufficiently detailed for a
|
||||
recipient of ordinary skill to be able to understand it.
|
||||
|
||||
5. Termination
|
||||
--------------
|
||||
|
||||
5.1. The rights granted under this License will terminate automatically
|
||||
if You fail to comply with any of its terms. However, if You become
|
||||
compliant, then the rights granted under this License from a particular
|
||||
Contributor are reinstated (a) provisionally, unless and until such
|
||||
Contributor explicitly and finally terminates Your grants, and (b) on an
|
||||
ongoing basis, if such Contributor fails to notify You of the
|
||||
non-compliance by some reasonable means prior to 60 days after You have
|
||||
come back into compliance. Moreover, Your grants from a particular
|
||||
Contributor are reinstated on an ongoing basis if such Contributor
|
||||
notifies You of the non-compliance by some reasonable means, this is the
|
||||
first time You have received notice of non-compliance with this License
|
||||
from such Contributor, and You become compliant prior to 30 days after
|
||||
Your receipt of the notice.
|
||||
|
||||
5.2. If You initiate litigation against any entity by asserting a patent
|
||||
infringement claim (excluding declaratory judgment actions,
|
||||
counter-claims, and cross-claims) alleging that a Contributor Version
|
||||
directly or indirectly infringes any patent, then the rights granted to
|
||||
You by any and all Contributors for the Covered Software under Section
|
||||
2.1 of this License shall terminate.
|
||||
|
||||
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
|
||||
end user license agreements (excluding distributors and resellers) which
|
||||
have been validly granted by You or Your distributors under this License
|
||||
prior to termination shall survive termination.
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 6. Disclaimer of Warranty *
|
||||
* ------------------------- *
|
||||
* *
|
||||
* Covered Software is provided under this License on an "as is" *
|
||||
* basis, without warranty of any kind, either expressed, implied, or *
|
||||
* statutory, including, without limitation, warranties that the *
|
||||
* Covered Software is free of defects, merchantable, fit for a *
|
||||
* particular purpose or non-infringing. The entire risk as to the *
|
||||
* quality and performance of the Covered Software is with You. *
|
||||
* Should any Covered Software prove defective in any respect, You *
|
||||
* (not any Contributor) assume the cost of any necessary servicing, *
|
||||
* repair, or correction. This disclaimer of warranty constitutes an *
|
||||
* essential part of this License. No use of any Covered Software is *
|
||||
* authorized under this License except under this disclaimer. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 7. Limitation of Liability *
|
||||
* -------------------------- *
|
||||
* *
|
||||
* Under no circumstances and under no legal theory, whether tort *
|
||||
* (including negligence), contract, or otherwise, shall any *
|
||||
* Contributor, or anyone who distributes Covered Software as *
|
||||
* permitted above, be liable to You for any direct, indirect, *
|
||||
* special, incidental, or consequential damages of any character *
|
||||
* including, without limitation, damages for lost profits, loss of *
|
||||
* goodwill, work stoppage, computer failure or malfunction, or any *
|
||||
* and all other commercial damages or losses, even if such party *
|
||||
* shall have been informed of the possibility of such damages. This *
|
||||
* limitation of liability shall not apply to liability for death or *
|
||||
* personal injury resulting from such party's negligence to the *
|
||||
* extent applicable law prohibits such limitation. Some *
|
||||
* jurisdictions do not allow the exclusion or limitation of *
|
||||
* incidental or consequential damages, so this exclusion and *
|
||||
* limitation may not apply to You. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
8. Litigation
|
||||
-------------
|
||||
|
||||
Any litigation relating to this License may be brought only in the
|
||||
courts of a jurisdiction where the defendant maintains its principal
|
||||
place of business and such litigation shall be governed by laws of that
|
||||
jurisdiction, without reference to its conflict-of-law provisions.
|
||||
Nothing in this Section shall prevent a party's ability to bring
|
||||
cross-claims or counter-claims.
|
||||
|
||||
9. Miscellaneous
|
||||
----------------
|
||||
|
||||
This License represents the complete agreement concerning the subject
|
||||
matter hereof. If any provision of this License is held to be
|
||||
unenforceable, such provision shall be reformed only to the extent
|
||||
necessary to make it enforceable. Any law or regulation which provides
|
||||
that the language of a contract shall be construed against the drafter
|
||||
shall not be used to construe this License against a Contributor.
|
||||
|
||||
10. Versions of the License
|
||||
---------------------------
|
||||
|
||||
10.1. New Versions
|
||||
|
||||
Mozilla Foundation is the license steward. Except as provided in Section
|
||||
10.3, no one other than the license steward has the right to modify or
|
||||
publish new versions of this License. Each version will be given a
|
||||
distinguishing version number.
|
||||
|
||||
10.2. Effect of New Versions
|
||||
|
||||
You may distribute the Covered Software under the terms of the version
|
||||
of the License under which You originally received the Covered Software,
|
||||
or under the terms of any subsequent version published by the license
|
||||
steward.
|
||||
|
||||
10.3. Modified Versions
|
||||
|
||||
If you create software not governed by this License, and you want to
|
||||
create a new license for such software, you may create and use a
|
||||
modified version of this License if you rename the license and remove
|
||||
any references to the name of the license steward (except to note that
|
||||
such modified license differs from this License).
|
||||
|
||||
10.4. Distributing Source Code Form that is Incompatible With Secondary
|
||||
Licenses
|
||||
|
||||
If You choose to distribute Source Code Form that is Incompatible With
|
||||
Secondary Licenses under the terms of this version of the License, the
|
||||
notice described in Exhibit B of this License must be attached.
|
||||
|
||||
Exhibit A - Source Code Form License Notice
|
||||
-------------------------------------------
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
If it is not possible or desirable to put the notice in a particular
|
||||
file, then You may include the notice in a location (such as a LICENSE
|
||||
file in a relevant directory) where a recipient would be likely to look
|
||||
for such a notice.
|
||||
|
||||
You may add additional accurate notices of copyright ownership.
|
||||
|
||||
Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||
---------------------------------------------------------
|
||||
|
||||
This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||
defined by the Mozilla Public License, v. 2.0.
|
168
README.md
168
README.md
@ -1,123 +1,139 @@
|
||||
# HSP Guard
|
||||
|
||||
**HSP Guard** is an internal security service for your home lab, designed to manage user access to various home services and tools. It dynamically controls permissions and prevents unauthorized or unexpected users from accessing sensitive services.
|
||||
# 🛡️ HSP Guard
|
||||
|
||||
**HSP Guard** is a modern OpenID Connect (OIDC) identity provider and access management system for home labs. It provides secure authentication and granular authorization for all your self-hosted services, combining ease of use with enterprise-level control — without any vendor lock-in.
|
||||
|
||||
---
|
||||
|
||||
## 📌 Overview
|
||||
## ✨ Features
|
||||
|
||||
HSP Guard authorizes user requests and provides an efficient way to:
|
||||
|
||||
- Manage permissions for individual services/tools
|
||||
- Define roles for easier access control
|
||||
- Validate and authorize users via JWT tokens
|
||||
- Securely integrate with new services during installation
|
||||
- **OIDC Provider**: Central login for your home lab apps
|
||||
- **Admin UI**: Manage apps, users, roles, permissions, and sessions
|
||||
- **API Tokens**: Issue access tokens with embedded roles and permissions
|
||||
- **Flexible Authorization**: Support for roles, permissions, and groups (future)
|
||||
- **App Registration**: Register OAuth/OIDC clients with custom permissions
|
||||
- **Automatic Permission Sync**: Optionally fetch app permissions from `/.well-known/guard-configuration`
|
||||
- **User & Admin Sessions**: See and revoke active user/app sessions
|
||||
- **Pluggable**: Easily integrate new apps and services
|
||||
- **Audit Logging**: Track actions for security and troubleshooting (planned)
|
||||
|
||||
---
|
||||
|
||||
## 📚 Concepts
|
||||
## 🚀 Getting Started
|
||||
|
||||
### 🔐 Permission
|
||||
### 1. **Run HSP Guard**
|
||||
|
||||
Permissions define access to specific features or tools.
|
||||
By default, HSP Guard includes predefined administrative permissions that allow an admin to log in and configure the system.
|
||||
You can run HSP Guard via Docker, Docker Compose, or natively (see below).
|
||||
|
||||
Once logged in, the admin can:
|
||||
### 2. **Register Your First App**
|
||||
|
||||
- Manually create new permissions for specific applications
|
||||
- Allow new applications to register their own permissions
|
||||
- Assign permissions to users, granting them access to corresponding tools
|
||||
1. **Login as admin**
|
||||
2. Go to **Apps → Register New App**
|
||||
3. Enter:
|
||||
- **Name** of your app
|
||||
- **Redirect URIs** (for OIDC/OAuth callbacks)
|
||||
- (Optional) **Permissions** (manual or auto-discovered from the app)
|
||||
4. Save to receive a `client_id` and `client_secret`
|
||||
5. Configure your app to use these for OIDC login
|
||||
|
||||
### 3. **Assign Permissions & Roles**
|
||||
|
||||
- Assign **default roles** to new users automatically (configurable)
|
||||
- Create custom **roles** to bundle permissions (e.g., `FAMILY_MEMBER`)
|
||||
- Assign users to roles and/or groups for flexible access control
|
||||
|
||||
---
|
||||
|
||||
### 🧩 Role
|
||||
## 🏗️ Concepts
|
||||
|
||||
A **Role** is a named collection of permissions (e.g., `GUEST`, `FRIEND`, `FAMILY_MEMBER`) created by the admin.
|
||||
Roles simplify user management by allowing bulk assignment of permissions. Instead of assigning multiple permissions individually, a role bundles them under one label.
|
||||
### 🔑 **Permissions**
|
||||
Fine-grained controls for app features (e.g., `music.play`, `dashboard.edit`).
|
||||
Can be manually defined or auto-discovered from an app’s `.well-known/guard-configuration` endpoint.
|
||||
|
||||
### 🧩 **Roles**
|
||||
Named bundles of permissions (e.g., `GUEST`, `FAMILY_MEMBER`, `ADMIN`).
|
||||
Assign to users/groups for easier management.
|
||||
|
||||
### 👥 **Groups**
|
||||
(Planned) Logical user collections (e.g., “Family”, “Guests”, “Admins”) for batch management of roles/permissions.
|
||||
|
||||
### 👤 **Users**
|
||||
Each user has a unique profile, roles, and group memberships.
|
||||
|
||||
---
|
||||
|
||||
### 👥 Group *(Coming Soon)*
|
||||
## 🔗 OIDC/OAuth Integration
|
||||
|
||||
This feature is planned for future releases. Groups will help organize users or services into logical clusters for simplified access control.
|
||||
**HSP Guard** is a standard-compliant OIDC Provider. Any app supporting OIDC/OAuth can integrate.
|
||||
|
||||
- Register app in admin panel to get `client_id` & `client_secret`
|
||||
- Configure your app’s OIDC integration (see your app’s docs)
|
||||
- Token claims include `permissions` and `roles` for easy authorization
|
||||
|
||||
#### **Example Token Claims**
|
||||
```json
|
||||
{
|
||||
"sub": "123456",
|
||||
"name": "Alex Example",
|
||||
"email": "alex@example.com",
|
||||
"roles": ["GUEST"],
|
||||
"permissions": ["dashboard.view", "music.play"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📡 API
|
||||
## 📡 **App Permission Discovery**
|
||||
|
||||
### ✅ User Authorization
|
||||
If your app supports permission discovery:
|
||||
- Expose `/.well-known/guard-configuration` endpoint listing available permissions
|
||||
- When registering in HSP Guard, auto-fetch and display for approval
|
||||
|
||||
To verify whether a request is made by a valid and authorized user, applications can require a **JWT token** as part of the request.
|
||||
This token is sent to HSP Guard, which:
|
||||
|
||||
- Validates the token
|
||||
- Returns user details (e.g., ID, name, email) for logging, auditing, or request tracing
|
||||
#### **Example guard-configuration JSON**
|
||||
```json
|
||||
{
|
||||
"permissions": [
|
||||
"dashboard.view",
|
||||
"dashboard.edit",
|
||||
"dashboard.admin"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🔑 Permission Checking
|
||||
## 🔄 **User & Admin Sessions**
|
||||
|
||||
Applications can also verify whether a user holds specific permissions before granting access to certain services or features.
|
||||
|
||||
To do this, an app sends:
|
||||
|
||||
- The user's JWT token
|
||||
- A list of required permissions
|
||||
|
||||
HSP Guard checks the user’s assigned permissions and responds with the authorization status.
|
||||
|
||||
> **Best Practice:** Applications should directly integrate with HSP Guard to enforce permission-based access control.
|
||||
- List all active sessions (browser, app, device, timestamp)
|
||||
- Revoke sessions (logout) from user or admin panel
|
||||
|
||||
---
|
||||
|
||||
## 🔄 User Authorization Flow
|
||||
## 📦 **Planned Features & Roadmap**
|
||||
|
||||
When a user tries to access a home lab service that requires authentication:
|
||||
|
||||
1. The application will **offer an authorization URL** to the user.
|
||||
2. The user follows the URL and is taken to the **HSP Guard login page**.
|
||||
3. The user selects or signs into an account they wish to use for that service.
|
||||
4. Once authenticated and authorized, the user is redirected to the **application-defined redirect URL**.
|
||||
5. The application can now:
|
||||
- Retrieve a **JWT token** from the redirect callback
|
||||
- **Optionally cache the session/token** to avoid prompting the user every time
|
||||
|
||||
This process is similar to how external identity providers like **Google Sign-In** or **GitHub OAuth** work — providing a seamless and secure authentication experience for the user.
|
||||
- [ ] **Group Management** for batch assignments
|
||||
- [ ] **Audit Logging** of all admin/user actions
|
||||
- [ ] **Permission Expiry** (time-limited access)
|
||||
- [ ] **Advanced Web UI** (dark mode, mobile)
|
||||
- [ ] **External Identity Providers** (login with Google, GitHub, etc.)
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Integrating New Services & Tools
|
||||
## 🛠️ **Development**
|
||||
|
||||
When a new service or tool is installed:
|
||||
|
||||
1. It provides a configuration file to HSP Guard
|
||||
2. Guard extracts and registers any defined permissions
|
||||
3. These permissions are **isolated** — even if a name overlaps with existing permissions, a prefix is added to avoid conflicts
|
||||
- See [CONTRIBUTING.md](CONTRIBUTING.md) for how to contribute!
|
||||
- Pull requests and issues are welcome.
|
||||
|
||||
---
|
||||
|
||||
## 👤 User Registration & Onboarding
|
||||
## 📝 **License**
|
||||
|
||||
New users (e.g., family, friends, guests) must complete a registration process to access your home lab.
|
||||
|
||||
They can:
|
||||
|
||||
- Visit a user-friendly registration webpage
|
||||
- Fill out a form with basic information (name, email, password, etc.)
|
||||
|
||||
Once registered, the admin can assign roles or individual permissions as needed.
|
||||
MIT — open source, for the home lab community.
|
||||
|
||||
---
|
||||
|
||||
## 🚧 Roadmap
|
||||
## 💬 **Feedback**
|
||||
|
||||
- [ ] Group Management
|
||||
- [ ] Web UI Enhancements
|
||||
- [ ] Audit Logging
|
||||
- [ ] Permission Expiry & Time-Based Access
|
||||
Open an [issue](https://github.com/yourusername/hsp-guard/issues) or join the discussion!
|
||||
|
||||
---
|
||||
|
||||
## 📬 Feedback & Contribution
|
||||
|
||||
Feel free to open an issue or pull request if you’d like to contribute or report bugs. HSP Guard is a personal home lab project, but feedback is always welcome!
|
||||
|
@ -8,6 +8,7 @@ import (
|
||||
|
||||
"gitea.local/admin/hspguard/internal/admin"
|
||||
"gitea.local/admin/hspguard/internal/auth"
|
||||
"gitea.local/admin/hspguard/internal/cache"
|
||||
"gitea.local/admin/hspguard/internal/config"
|
||||
"gitea.local/admin/hspguard/internal/oauth"
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
@ -21,14 +22,16 @@ type APIServer struct {
|
||||
addr string
|
||||
repo *repository.Queries
|
||||
storage *storage.FileStorage
|
||||
cache *cache.Client
|
||||
cfg *config.AppConfig
|
||||
}
|
||||
|
||||
func NewAPIServer(addr string, db *repository.Queries, minio *storage.FileStorage, cfg *config.AppConfig) *APIServer {
|
||||
func NewAPIServer(addr string, db *repository.Queries, minio *storage.FileStorage, cache *cache.Client, cfg *config.AppConfig) *APIServer {
|
||||
return &APIServer{
|
||||
addr: addr,
|
||||
repo: db,
|
||||
storage: minio,
|
||||
cache: cache,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
@ -41,13 +44,13 @@ func (s *APIServer) Run() error {
|
||||
// staticDir := http.Dir(filepath.Join(workDir, "static"))
|
||||
// FileServer(router, "/static", staticDir)
|
||||
|
||||
oauthHandler := oauth.NewOAuthHandler(s.repo, s.cfg)
|
||||
oauthHandler := oauth.NewOAuthHandler(s.repo, s.cache, s.cfg)
|
||||
|
||||
router.Route("/api/v1", func(r chi.Router) {
|
||||
userHandler := user.NewUserHandler(s.repo, s.storage, s.cfg)
|
||||
userHandler.RegisterRoutes(r)
|
||||
|
||||
authHandler := auth.NewAuthHandler(s.repo, s.cfg)
|
||||
authHandler := auth.NewAuthHandler(s.repo, s.cache, s.cfg)
|
||||
authHandler.RegisterRoutes(r)
|
||||
|
||||
oauthHandler.RegisterRoutes(r)
|
||||
|
@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
|
||||
"gitea.local/admin/hspguard/cmd/hspguard/api"
|
||||
"gitea.local/admin/hspguard/internal/cache"
|
||||
"gitea.local/admin/hspguard/internal/config"
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"gitea.local/admin/hspguard/internal/storage"
|
||||
@ -41,9 +42,11 @@ func main() {
|
||||
|
||||
fStorage := storage.New(&cfg)
|
||||
|
||||
cache := cache.NewClient(&cfg)
|
||||
|
||||
user.EnsureAdminUser(ctx, &cfg, repo)
|
||||
|
||||
server := api.NewAPIServer(fmt.Sprintf("%s:%s", cfg.Host, cfg.Port), repo, fStorage, &cfg)
|
||||
server := api.NewAPIServer(fmt.Sprintf("%s:%s", cfg.Host, cfg.Port), repo, fStorage, cache, &cfg)
|
||||
if err := server.Run(); err != nil {
|
||||
log.Fatalln("ERR: Failed to start server:", err)
|
||||
}
|
||||
|
@ -8,3 +8,18 @@ services:
|
||||
POSTGRES_PASSWORD: guard
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
cache:
|
||||
image: redis:7.2 # or newer
|
||||
container_name: guard-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
- ./redis.conf:/usr/local/etc/redis/redis.conf
|
||||
command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
driver: local
|
||||
|
4
go.mod
4
go.mod
@ -11,6 +11,9 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/avct/uasurfer v0.0.0-20250506104815-f2613aa2d406 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
@ -23,6 +26,7 @@ require (
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/minio/minio-go/v7 v7.0.92 // indirect
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
|
||||
github.com/redis/go-redis/v9 v9.10.0 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/tinylib/msgp v1.3.0 // indirect
|
||||
golang.org/x/crypto v0.38.0 // indirect
|
||||
|
8
go.sum
8
go.sum
@ -1,6 +1,12 @@
|
||||
github.com/avct/uasurfer v0.0.0-20250506104815-f2613aa2d406 h1:5/KfwL9TS8yNtUSunutqifcSC8rdX9PNdvbSsw/X/lQ=
|
||||
github.com/avct/uasurfer v0.0.0-20250506104815-f2613aa2d406/go.mod h1:s+GCtuP4kZNxh1WGoqdWI1+PbluBcycrMMWuKQ9e5Nk=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
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/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||
@ -38,6 +44,8 @@ github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1Gsh
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.10.0 h1:FxwK3eV8p/CQa0Ch276C7u2d0eNC9kCmAYQ7mCXCzVs=
|
||||
github.com/redis/go-redis/v9 v9.10.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
|
@ -4,43 +4,15 @@ import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"gitea.local/admin/hspguard/internal/types"
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ApiServiceDTO struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ClientID string `json:"client_id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
RedirectUris []string `json:"redirect_uris"`
|
||||
Scopes []string `json:"scopes"`
|
||||
GrantTypes []string `json:"grant_types"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
func NewApiServiceDTO(service repository.ApiService) ApiServiceDTO {
|
||||
return ApiServiceDTO{
|
||||
ID: service.ID,
|
||||
ClientID: service.ClientID,
|
||||
Name: service.Name,
|
||||
Description: service.Description,
|
||||
RedirectUris: service.RedirectUris,
|
||||
Scopes: service.Scopes,
|
||||
GrantTypes: service.GrantTypes,
|
||||
CreatedAt: service.CreatedAt,
|
||||
UpdatedAt: service.UpdatedAt,
|
||||
IsActive: service.IsActive,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetApiServices(w http.ResponseWriter, r *http.Request) {
|
||||
services, err := h.repo.ListApiServices(r.Context())
|
||||
if err != nil {
|
||||
@ -49,14 +21,14 @@ func (h *AdminHandler) GetApiServices(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
apiServices := make([]ApiServiceDTO, 0)
|
||||
apiServices := make([]types.ApiServiceDTO, 0)
|
||||
|
||||
for _, service := range services {
|
||||
apiServices = append(apiServices, NewApiServiceDTO(service))
|
||||
apiServices = append(apiServices, types.NewApiServiceDTO(service))
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Items []ApiServiceDTO `json:"items"`
|
||||
Items []types.ApiServiceDTO `json:"items"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
@ -144,7 +116,7 @@ func (h *AdminHandler) AddApiService(w http.ResponseWriter, r *http.Request) {
|
||||
service.ClientSecret = clientSecret
|
||||
|
||||
type Response struct {
|
||||
Service ApiServiceDTO `json:"service"`
|
||||
Service types.ApiServiceDTO `json:"service"`
|
||||
Credentials ApiServiceCredentials `json:"credentials"`
|
||||
}
|
||||
|
||||
@ -153,7 +125,7 @@ func (h *AdminHandler) AddApiService(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(Response{
|
||||
Service: NewApiServiceDTO(service),
|
||||
Service: types.NewApiServiceDTO(service),
|
||||
Credentials: ApiServiceCredentials{
|
||||
ClientId: service.ClientID,
|
||||
ClientSecret: service.ClientSecret,
|
||||
@ -181,7 +153,25 @@ func (h *AdminHandler) GetApiService(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(NewApiServiceDTO(service)); err != nil {
|
||||
if err := encoder.Encode(types.NewApiServiceDTO(service)); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetApiServiceCID(w http.ResponseWriter, r *http.Request) {
|
||||
clientId := chi.URLParam(r, "client_id")
|
||||
|
||||
service, err := h.repo.GetApiServiceCID(r.Context(), clientId)
|
||||
if err != nil {
|
||||
web.Error(w, "service with provided client id not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(types.NewApiServiceDTO(service)); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@ -283,7 +273,7 @@ func (h *AdminHandler) UpdateApiService(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(NewApiServiceDTO(updated)); err != nil {
|
||||
if err := encoder.Encode(types.NewApiServiceDTO(updated)); err != nil {
|
||||
web.Error(w, "failed to send updated api service", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
@ -21,7 +21,7 @@ func New(repo *repository.Queries, cfg *config.AppConfig) *AdminHandler {
|
||||
|
||||
func (h *AdminHandler) RegisterRoutes(router chi.Router) {
|
||||
router.Route("/admin", func(r chi.Router) {
|
||||
authMiddleware := imiddleware.NewAuthMiddleware(h.cfg)
|
||||
authMiddleware := imiddleware.NewAuthMiddleware(h.cfg, h.repo)
|
||||
adminMiddleware := imiddleware.NewAdminMiddleware(h.repo)
|
||||
r.Use(authMiddleware.Runner, adminMiddleware.Runner)
|
||||
|
||||
@ -35,5 +35,13 @@ func (h *AdminHandler) RegisterRoutes(router chi.Router) {
|
||||
r.Get("/users", h.GetUsers)
|
||||
r.Post("/users", h.CreateUser)
|
||||
r.Get("/users/{id}", h.GetUser)
|
||||
|
||||
r.Get("/user-sessions", h.GetUserSessions)
|
||||
r.Patch("/user-sessions/revoke/{id}", h.RevokeUserSession)
|
||||
|
||||
r.Get("/service-sessions", h.GetServiceSessions)
|
||||
r.Patch("/service-sessions/revoke/{id}", h.RevokeUserSession)
|
||||
})
|
||||
|
||||
router.Get("/api-services/client/{client_id}", h.GetApiServiceCID)
|
||||
}
|
||||
|
182
internal/admin/sessions.go
Normal file
182
internal/admin/sessions.go
Normal file
@ -0,0 +1,182 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"gitea.local/admin/hspguard/internal/types"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type GetSessionsParams struct {
|
||||
PageSize int `json:"size"`
|
||||
Page int `json:"page"`
|
||||
// TODO: More filtering possibilities like onlyActive, expired, not-expired etc.
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetUserSessions(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
|
||||
params := GetSessionsParams{}
|
||||
|
||||
if pageSize, err := strconv.Atoi(q.Get("size")); err == nil {
|
||||
params.PageSize = pageSize
|
||||
} else {
|
||||
params.PageSize = 15
|
||||
}
|
||||
|
||||
if page, err := strconv.Atoi(q.Get("page")); err == nil {
|
||||
params.Page = page
|
||||
} else {
|
||||
web.Error(w, "page is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
sessions, err := h.repo.GetUserSessions(r.Context(), repository.GetUserSessionsParams{
|
||||
Limit: int32(params.PageSize),
|
||||
Offset: int32(params.Page-1) * int32(params.PageSize),
|
||||
})
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to read user sessions from db:", err)
|
||||
web.Error(w, "failed to retrieve sessions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
totalSessions, err := h.repo.GetUserSessionsCount(r.Context())
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to get total count of user sessions:", err)
|
||||
web.Error(w, "failed to retrieve sessions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
mapped := make([]*types.UserSessionDTO, 0)
|
||||
|
||||
for _, session := range sessions {
|
||||
mapped = append(mapped, types.NewUserSessionDTO(&session))
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Items []*types.UserSessionDTO `json:"items"`
|
||||
Page int `json:"page"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
response := Response{
|
||||
Items: mapped,
|
||||
Page: params.Page,
|
||||
TotalPages: int(math.Ceil(float64(totalSessions) / float64(params.PageSize))),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
log.Println("ERR: Failed to encode sessions in response:", err)
|
||||
web.Error(w, "failed to encode sessions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) RevokeUserSession(w http.ResponseWriter, r *http.Request) {
|
||||
sessionId := chi.URLParam(r, "id")
|
||||
parsed, err := uuid.Parse(sessionId)
|
||||
if err != nil {
|
||||
web.Error(w, "provided service id is not valid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.RevokeUserSession(r.Context(), parsed); err != nil {
|
||||
log.Println("ERR: Failed to revoke user session:", err)
|
||||
web.Error(w, "failed to revoke user session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("{\"success\":true}"))
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetServiceSessions(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
|
||||
params := GetSessionsParams{}
|
||||
|
||||
if pageSize, err := strconv.Atoi(q.Get("size")); err == nil {
|
||||
params.PageSize = pageSize
|
||||
} else {
|
||||
params.PageSize = 15
|
||||
}
|
||||
|
||||
if page, err := strconv.Atoi(q.Get("page")); err == nil {
|
||||
params.Page = page
|
||||
} else {
|
||||
web.Error(w, "page is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
sessions, err := h.repo.GetServiceSessions(r.Context(), repository.GetServiceSessionsParams{
|
||||
Limit: int32(params.PageSize),
|
||||
Offset: int32(params.Page-1) * int32(params.PageSize),
|
||||
})
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to read api sessions from db:", err)
|
||||
web.Error(w, "failed to retrieve sessions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
totalSessions, err := h.repo.GetServiceSessionsCount(r.Context())
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to get total count of service sessions:", err)
|
||||
web.Error(w, "failed to retrieve sessions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
mapped := make([]*types.ServiceSessionDTO, 0)
|
||||
|
||||
for _, session := range sessions {
|
||||
mapped = append(mapped, types.NewServiceSessionDTO(&session))
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Items []*types.ServiceSessionDTO `json:"items"`
|
||||
Page int `json:"page"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
response := Response{
|
||||
Items: mapped,
|
||||
Page: params.Page,
|
||||
TotalPages: int(math.Ceil(float64(totalSessions) / float64(params.PageSize))),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
log.Println("ERR: Failed to encode sessions in response:", err)
|
||||
web.Error(w, "failed to encode sessions", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) RevokeServiceSession(w http.ResponseWriter, r *http.Request) {
|
||||
sessionId := chi.URLParam(r, "id")
|
||||
parsed, err := uuid.Parse(sessionId)
|
||||
if err != nil {
|
||||
web.Error(w, "provided service id is not valid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.RevokeServiceSession(r.Context(), parsed); err != nil {
|
||||
log.Println("ERR: Failed to revoke service session:", err)
|
||||
web.Error(w, "failed to revoke service session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("{\"success\":true}"))
|
||||
}
|
107
internal/auth/login.go
Normal file
107
internal/auth/login.go
Normal file
@ -0,0 +1,107 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
)
|
||||
|
||||
type LoginParams struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) {
|
||||
var params LoginParams
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
if err := decoder.Decode(¶ms); err != nil {
|
||||
web.Error(w, "failed to parse request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if params.Email == "" || params.Password == "" {
|
||||
web.Error(w, "missing required fields", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("DEBUG: looking for user with following params: %#v\n", params)
|
||||
|
||||
user, err := h.repo.FindUserEmail(r.Context(), params.Email)
|
||||
if err != nil {
|
||||
log.Printf("DEBUG: No user found with '%s' email: %v\n", params.Email, err)
|
||||
web.Error(w, "email or/and password are incorrect", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !util.VerifyPassword(params.Password, user.PasswordHash) {
|
||||
log.Printf("DEBUG: Incorrect password '%s' for '%s' email: %v\n", params.Password, params.Email, err)
|
||||
web.Error(w, "email or/and password are incorrect", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
access, refresh, err := h.signTokens(&user)
|
||||
if err != nil {
|
||||
web.Error(w, "failed to generate tokens", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
userAgent := r.UserAgent()
|
||||
|
||||
ipAddr := util.GetClientIP(r)
|
||||
deviceInfo := util.BuildDeviceInfo(userAgent, ipAddr)
|
||||
|
||||
// Create User Session
|
||||
session, err := h.repo.CreateUserSession(r.Context(), repository.CreateUserSessionParams{
|
||||
UserID: user.ID,
|
||||
SessionType: "user",
|
||||
ExpiresAt: &refresh.ExpiresAt,
|
||||
LastActive: nil,
|
||||
IpAddress: &ipAddr,
|
||||
UserAgent: &userAgent,
|
||||
AccessTokenID: &access.ID,
|
||||
RefreshTokenID: &refresh.ID,
|
||||
DeviceInfo: deviceInfo,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to create user session after logging in: %v\n", err)
|
||||
}
|
||||
|
||||
log.Printf("INFO: User session created for '%s' with '%s' id\n", user.Email, session.ID.String())
|
||||
|
||||
if err := h.repo.UpdateLastLogin(r.Context(), user.ID); err != nil {
|
||||
web.Error(w, "failed to update user's last login", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
type Response struct {
|
||||
AccessToken string `json:"access"`
|
||||
RefreshToken string `json:"refresh"`
|
||||
// fields required for UI in account selector, e.g. email, full name and avatar
|
||||
FullName string `json:"full_name"`
|
||||
Email string `json:"email"`
|
||||
Id string `json:"id"`
|
||||
ProfilePicture *string `json:"profile_picture"`
|
||||
// Avatar
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(Response{
|
||||
AccessToken: access.Token,
|
||||
RefreshToken: refresh.Token,
|
||||
FullName: user.FullName,
|
||||
Email: user.Email,
|
||||
Id: user.ID.String(),
|
||||
ProfilePicture: user.ProfilePicture,
|
||||
// Avatar
|
||||
}); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
31
internal/auth/profile.go
Normal file
31
internal/auth/profile.go
Normal file
@ -0,0 +1,31 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/types"
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (h *AuthHandler) getProfile(w http.ResponseWriter, r *http.Request) {
|
||||
userId, ok := util.GetRequestUserId(r.Context())
|
||||
if !ok {
|
||||
web.Error(w, "failed to get user id from auth session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(userId))
|
||||
if err != nil {
|
||||
web.Error(w, "user with provided id does not exist", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(types.NewUserDTO(&user)); err != nil {
|
||||
web.Error(w, "failed to encode user profile", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
122
internal/auth/refresh.go
Normal file
122
internal/auth/refresh.go
Normal file
@ -0,0 +1,122 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"gitea.local/admin/hspguard/internal/types"
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (h *AuthHandler) refreshToken(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
web.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.Split(authHeader, "Bearer ")
|
||||
if len(parts) != 2 {
|
||||
web.Error(w, "invalid auth header format", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := parts[1]
|
||||
var userClaims types.UserClaims
|
||||
|
||||
token, err := util.VerifyToken(tokenStr, h.cfg.Jwt.PublicKey, &userClaims)
|
||||
if err != nil || !token.Valid {
|
||||
http.Error(w, fmt.Sprintf("invalid token: %v", err), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
expire, err := userClaims.GetExpirationTime()
|
||||
if err != nil {
|
||||
web.Error(w, "failed to retrieve enough info from the token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if time.Now().After(expire.Time) {
|
||||
web.Error(w, "token is expired", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userId, err := uuid.Parse(userClaims.Subject)
|
||||
if err != nil {
|
||||
web.Error(w, "failed to parsej user id from token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.repo.FindUserId(r.Context(), userId)
|
||||
if err != nil {
|
||||
web.Error(w, "user with provided email does not exists", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
access, refresh, err := h.signTokens(&user)
|
||||
if err != nil {
|
||||
web.Error(w, "failed to generate tokens", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
jti, err := uuid.Parse(userClaims.ID)
|
||||
if session, err := h.repo.GetUserSessionByRefreshJTI(r.Context(), &jti); err != nil {
|
||||
log.Printf("WARN: No existing user session found for user with '%s' email (jti: '%s'): %v\n", user.Email, userClaims.ID, err)
|
||||
|
||||
userAgent := r.UserAgent()
|
||||
|
||||
ipAddr := util.GetClientIP(r)
|
||||
deviceInfo := util.BuildDeviceInfo(userAgent, ipAddr)
|
||||
|
||||
// Create User Session
|
||||
session, err := h.repo.CreateUserSession(r.Context(), repository.CreateUserSessionParams{
|
||||
UserID: user.ID,
|
||||
SessionType: "user",
|
||||
ExpiresAt: &refresh.ExpiresAt,
|
||||
LastActive: nil,
|
||||
IpAddress: &ipAddr,
|
||||
UserAgent: &userAgent,
|
||||
AccessTokenID: &access.ID,
|
||||
RefreshTokenID: &refresh.ID,
|
||||
DeviceInfo: deviceInfo,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to create user session after logging in: %v\n", err)
|
||||
}
|
||||
|
||||
log.Printf("INFO: User session created for '%s' with '%s' id\n", user.Email, session.ID.String())
|
||||
} else {
|
||||
err := h.repo.UpdateSessionTokens(r.Context(), repository.UpdateSessionTokensParams{
|
||||
ID: session.ID,
|
||||
AccessTokenID: &access.ID,
|
||||
RefreshTokenID: &refresh.ID,
|
||||
ExpiresAt: &refresh.ExpiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to update user session with '%s' id: %v\n", session.ID.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
AccessToken string `json:"access"`
|
||||
RefreshToken string `json:"refresh"`
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(Response{
|
||||
AccessToken: access.Token,
|
||||
RefreshToken: refresh.Token,
|
||||
}); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
@ -1,19 +1,14 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/cache"
|
||||
"gitea.local/admin/hspguard/internal/config"
|
||||
imiddleware "gitea.local/admin/hspguard/internal/middleware"
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"gitea.local/admin/hspguard/internal/types"
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
@ -21,10 +16,14 @@ import (
|
||||
|
||||
type AuthHandler struct {
|
||||
repo *repository.Queries
|
||||
cache *cache.Client
|
||||
cfg *config.AppConfig
|
||||
}
|
||||
|
||||
func (h *AuthHandler) signTokens(user *repository.User) (string, string, error) {
|
||||
func (h *AuthHandler) signTokens(user *repository.User) (*types.SignedToken, *types.SignedToken, error) {
|
||||
accessExpiresAt := time.Now().Add(15 * time.Minute)
|
||||
accessJTI := uuid.New()
|
||||
|
||||
accessClaims := types.UserClaims{
|
||||
UserEmail: user.Email,
|
||||
IsAdmin: user.IsAdmin,
|
||||
@ -32,15 +31,19 @@ func (h *AuthHandler) signTokens(user *repository.User) (string, string, error)
|
||||
Issuer: h.cfg.Uri,
|
||||
Subject: user.ID.String(),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
|
||||
ExpiresAt: jwt.NewNumericDate(accessExpiresAt),
|
||||
ID: accessJTI.String(),
|
||||
},
|
||||
}
|
||||
|
||||
accessToken, err := util.SignJwtToken(accessClaims, h.cfg.Jwt.PrivateKey)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
refreshExpiresAt := time.Now().Add(30 * 24 * time.Hour)
|
||||
refreshJTI := uuid.New()
|
||||
|
||||
refreshClaims := types.UserClaims{
|
||||
UserEmail: user.Email,
|
||||
IsAdmin: user.IsAdmin,
|
||||
@ -48,21 +51,23 @@ func (h *AuthHandler) signTokens(user *repository.User) (string, string, error)
|
||||
Issuer: h.cfg.Uri,
|
||||
Subject: user.ID.String(),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * 24 * time.Hour)),
|
||||
ExpiresAt: jwt.NewNumericDate(refreshExpiresAt),
|
||||
ID: refreshJTI.String(),
|
||||
},
|
||||
}
|
||||
|
||||
refreshToken, err := util.SignJwtToken(refreshClaims, h.cfg.Jwt.PrivateKey)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return accessToken, refreshToken, nil
|
||||
return types.NewSignedToken(accessToken, accessExpiresAt, accessJTI), types.NewSignedToken(refreshToken, refreshExpiresAt, refreshJTI), nil
|
||||
}
|
||||
|
||||
func NewAuthHandler(repo *repository.Queries, cfg *config.AppConfig) *AuthHandler {
|
||||
func NewAuthHandler(repo *repository.Queries, cache *cache.Client, cfg *config.AppConfig) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
repo,
|
||||
cache,
|
||||
cfg,
|
||||
}
|
||||
}
|
||||
@ -70,170 +75,17 @@ func NewAuthHandler(repo *repository.Queries, cfg *config.AppConfig) *AuthHandle
|
||||
func (h *AuthHandler) RegisterRoutes(api chi.Router) {
|
||||
api.Route("/auth", func(r chi.Router) {
|
||||
r.Group(func(protected chi.Router) {
|
||||
authMiddleware := imiddleware.NewAuthMiddleware(h.cfg)
|
||||
authMiddleware := imiddleware.NewAuthMiddleware(h.cfg, h.repo)
|
||||
protected.Use(authMiddleware.Runner)
|
||||
|
||||
protected.Get("/profile", h.getProfile)
|
||||
protected.Post("/email", h.requestEmailOtp)
|
||||
protected.Post("/email/otp", h.confirmOtp)
|
||||
protected.Post("/verify", h.finishVerification)
|
||||
protected.Post("/signout", h.signOut)
|
||||
})
|
||||
|
||||
r.Post("/login", h.login)
|
||||
r.Post("/refresh", h.refreshToken)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) refreshToken(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
web.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.Split(authHeader, "Bearer ")
|
||||
if len(parts) != 2 {
|
||||
web.Error(w, "invalid auth header format", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := parts[1]
|
||||
token, userClaims, err := util.VerifyToken(tokenStr, h.cfg.Jwt.PublicKey)
|
||||
if err != nil || !token.Valid {
|
||||
http.Error(w, fmt.Sprintf("invalid token: %v", err), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
expire, err := userClaims.GetExpirationTime()
|
||||
if err != nil {
|
||||
web.Error(w, "failed to retrieve enough info from the token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if time.Now().After(expire.Time) {
|
||||
web.Error(w, "token is expired", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userId, err := uuid.Parse(userClaims.Subject)
|
||||
if err != nil {
|
||||
web.Error(w, "failed to parsej user id from token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.repo.FindUserId(r.Context(), userId)
|
||||
if err != nil {
|
||||
web.Error(w, "user with provided email does not exists", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
access, refresh, err := h.signTokens(&user)
|
||||
if err != nil {
|
||||
web.Error(w, "failed to generate tokens", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
AccessToken string `json:"access"`
|
||||
RefreshToken string `json:"refresh"`
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(Response{
|
||||
AccessToken: access,
|
||||
RefreshToken: refresh,
|
||||
}); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthHandler) getProfile(w http.ResponseWriter, r *http.Request) {
|
||||
userId, ok := util.GetRequestUserId(r.Context())
|
||||
if !ok {
|
||||
web.Error(w, "failed to get user id from auth session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(userId))
|
||||
if err != nil {
|
||||
web.Error(w, "user with provided id does not exist", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(types.NewUserDTO(&user)); err != nil {
|
||||
web.Error(w, "failed to encode user profile", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type LoginParams struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) {
|
||||
var params LoginParams
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
if err := decoder.Decode(¶ms); err != nil {
|
||||
web.Error(w, "failed to parse request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if params.Email == "" || params.Password == "" {
|
||||
web.Error(w, "missing required fields", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("DEBUG: looking for user with following params: %#v\n", params)
|
||||
|
||||
user, err := h.repo.FindUserEmail(r.Context(), params.Email)
|
||||
if err != nil {
|
||||
web.Error(w, "user with provided email does not exists", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !util.VerifyPassword(params.Password, user.PasswordHash) {
|
||||
web.Error(w, "username or/and password are incorrect", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
access, refresh, err := h.signTokens(&user)
|
||||
if err != nil {
|
||||
web.Error(w, "failed to generate tokens", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.UpdateLastLogin(r.Context(), user.ID); err != nil {
|
||||
web.Error(w, "failed to update user's last login", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
type Response struct {
|
||||
AccessToken string `json:"access"`
|
||||
RefreshToken string `json:"refresh"`
|
||||
// fields required for UI in account selector, e.g. email, full name and avatar
|
||||
FullName string `json:"full_name"`
|
||||
Email string `json:"email"`
|
||||
Id string `json:"id"`
|
||||
ProfilePicture *string `json:"profile_picture"`
|
||||
// Avatar
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(Response{
|
||||
AccessToken: access,
|
||||
RefreshToken: refresh,
|
||||
FullName: user.FullName,
|
||||
Email: user.Email,
|
||||
Id: user.ID.String(),
|
||||
ProfilePicture: user.ProfilePicture,
|
||||
// Avatar
|
||||
}); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
40
internal/auth/signout.go
Normal file
40
internal/auth/signout.go
Normal file
@ -0,0 +1,40 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (h *AuthHandler) signOut(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("{\"status\": \"ok\"}"))
|
||||
}()
|
||||
|
||||
jti, ok := util.GetRequestJTI(r.Context())
|
||||
if !ok {
|
||||
log.Println("WARN: No JTI found in request")
|
||||
return
|
||||
}
|
||||
|
||||
jtiId, err := uuid.Parse(jti)
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to parse jti '%s' as v4 uuid: %v\n", jti, err)
|
||||
return
|
||||
}
|
||||
|
||||
session, err := h.repo.GetUserSessionByAccessJTI(r.Context(), &jtiId)
|
||||
if err != nil {
|
||||
log.Printf("WARN: Could not find session by jti id '%s': %v\n", jtiId.String(), err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.RevokeUserSession(r.Context(), session.ID); err != nil {
|
||||
log.Printf("ERR: Failed to revoke session with '%s' id: %v\n", session.ID.String(), err)
|
||||
} else {
|
||||
log.Printf("INFO: Revoked session with jti = '%s' and session id = '%s'\n", jtiId.String(), session.ID.String())
|
||||
}
|
||||
}
|
126
internal/auth/verify.go
Normal file
126
internal/auth/verify.go
Normal file
@ -0,0 +1,126 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (h *AuthHandler) requestEmailOtp(w http.ResponseWriter, r *http.Request) {
|
||||
userId, ok := util.GetRequestUserId(r.Context())
|
||||
if !ok {
|
||||
web.Error(w, "failed to get user id from auth session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(userId))
|
||||
if err != nil {
|
||||
web.Error(w, "user with provided id does not exist", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if user.EmailVerified {
|
||||
web.Error(w, "email is already verified", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
number := rand.Intn(1000000) // 0 to 999999
|
||||
padded := fmt.Sprintf("%06d", number) // Always 6 characters
|
||||
|
||||
if _, err := h.cache.Set(r.Context(), fmt.Sprintf("otp-%s", user.ID.String()), padded, 5*time.Minute).Result(); err != nil {
|
||||
log.Println("ERR: Failed to save OTP in cache:", err)
|
||||
web.Error(w, "failed to generate otp", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("INFO: Saved OTP %s\n", padded)
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
type ConfirmOtpRequest struct {
|
||||
OTP string `json:"otp"`
|
||||
}
|
||||
|
||||
func (h *AuthHandler) confirmOtp(w http.ResponseWriter, r *http.Request) {
|
||||
userId, ok := util.GetRequestUserId(r.Context())
|
||||
if !ok {
|
||||
web.Error(w, "failed to get user id from auth session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(userId))
|
||||
if err != nil {
|
||||
web.Error(w, "user with provided id does not exist", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if user.EmailVerified {
|
||||
web.Error(w, "email is already verified", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req ConfirmOtpRequest
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
if err := decoder.Decode(&req); err != nil {
|
||||
web.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
val, err := h.cache.Get(r.Context(), fmt.Sprintf("otp-%s", user.ID.String())).Result()
|
||||
if err != nil {
|
||||
web.Error(w, "otp verification session not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("INFO: Comparing OTP %s == %s\n", req.OTP, val)
|
||||
|
||||
if req.OTP == val {
|
||||
err := h.repo.UserVerifyEmail(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to update email_verified:", err)
|
||||
web.Error(w, "failed to verify email", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
web.Error(w, "otp verification failed", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) finishVerification(w http.ResponseWriter, r *http.Request) {
|
||||
userId, ok := util.GetRequestUserId(r.Context())
|
||||
if !ok {
|
||||
web.Error(w, "failed to get user id from auth session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(userId))
|
||||
if err != nil {
|
||||
web.Error(w, "user with provided id does not exist", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if !user.EmailVerified || !user.AvatarVerified {
|
||||
web.Error(w, "finish other verification steps before final verify", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.UserVerifyComplete(r.Context(), user.ID); err != nil {
|
||||
log.Println("ERR: Failed to update verified on user:", err)
|
||||
web.Error(w, "failed to verify user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
84
internal/cache/mod.go
vendored
Normal file
84
internal/cache/mod.go
vendored
Normal file
@ -0,0 +1,84 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/config"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
rClient *redis.Client
|
||||
}
|
||||
|
||||
func NewClient(cfg *config.AppConfig) *Client {
|
||||
opts, err := redis.ParseURL(cfg.RedisURL)
|
||||
if err != nil {
|
||||
log.Fatalln("ERR: Failed to get redis options:", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
client := redis.NewClient(opts)
|
||||
|
||||
return &Client{
|
||||
rClient: client,
|
||||
}
|
||||
}
|
||||
|
||||
type OAuthCode struct {
|
||||
ClientID string `json:"client_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Nonce string `json:"nonce"`
|
||||
}
|
||||
|
||||
type SaveAuthCodeParams struct {
|
||||
AuthCode string
|
||||
UserID string
|
||||
ClientID string
|
||||
Nonce string
|
||||
}
|
||||
|
||||
func (c *Client) Set(ctx context.Context, key string, value any, expiration time.Duration) *redis.StatusCmd {
|
||||
return c.rClient.Set(ctx, key, value, expiration)
|
||||
}
|
||||
|
||||
func (c *Client) SaveAuthCode(ctx context.Context, params *SaveAuthCodeParams) error {
|
||||
code := OAuthCode{
|
||||
ClientID: params.ClientID,
|
||||
UserID: params.UserID,
|
||||
Nonce: params.Nonce,
|
||||
}
|
||||
row, err := json.Marshal(&code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.Set(ctx, fmt.Sprintf("oauth.%s", params.AuthCode), string(row), 5*time.Minute).Err()
|
||||
}
|
||||
|
||||
func (c *Client) GetAuthCode(ctx context.Context, authCode string) (*OAuthCode, error) {
|
||||
row, err := c.Get(ctx, fmt.Sprintf("oauth.%s", authCode)).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(row) == 0 {
|
||||
return nil, fmt.Errorf("no auth params found under %s", authCode)
|
||||
}
|
||||
|
||||
var parsed OAuthCode
|
||||
|
||||
if err := json.Unmarshal([]byte(row), &parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &parsed, nil
|
||||
}
|
||||
|
||||
func (c *Client) Get(ctx context.Context, key string) *redis.StringCmd {
|
||||
return c.rClient.Get(ctx, key)
|
||||
}
|
@ -14,6 +14,7 @@ type AppConfig struct {
|
||||
Host string `env:"GUARD_HOST" default:"127.0.0.1"`
|
||||
Uri string `env:"GUARD_URI" default:"http://127.0.0.1:3001"`
|
||||
DatabaseURL string `env:"GUARD_DB_URL" required:"true"`
|
||||
RedisURL string `env:"GUARD_REDIS_URL" default:"redis://localhost:6379/0"`
|
||||
Admin AdminConfig
|
||||
Jwt JwtConfig
|
||||
Minio MinioConfig
|
||||
|
@ -3,22 +3,27 @@ package middleware
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/config"
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"gitea.local/admin/hspguard/internal/types"
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type AuthMiddleware struct {
|
||||
cfg *config.AppConfig
|
||||
repo *repository.Queries
|
||||
}
|
||||
|
||||
func NewAuthMiddleware(cfg *config.AppConfig) *AuthMiddleware {
|
||||
func NewAuthMiddleware(cfg *config.AppConfig, repo *repository.Queries) *AuthMiddleware {
|
||||
return &AuthMiddleware{
|
||||
cfg,
|
||||
repo,
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,13 +42,36 @@ func (m *AuthMiddleware) Runner(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
tokenStr := parts[1]
|
||||
token, userClaims, err := util.VerifyToken(tokenStr, m.cfg.Jwt.PublicKey)
|
||||
var userClaims types.UserClaims
|
||||
|
||||
token, err := util.VerifyToken(tokenStr, m.cfg.Jwt.PublicKey, &userClaims)
|
||||
if err != nil || !token.Valid {
|
||||
web.Error(w, fmt.Sprintf("invalid token: %v", err), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: redis caching
|
||||
parsed, err := uuid.Parse(userClaims.ID)
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to parse token JTI '%s': %v\n", userClaims.ID, err)
|
||||
web.Error(w, "failed to get session", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
session, err := m.repo.GetUserSessionByAccessJTI(r.Context(), &parsed)
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to find session with '%s' JTI: %v\n", parsed.String(), err)
|
||||
web.Error(w, "no session found", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if !session.IsActive {
|
||||
log.Printf("INFO: Inactive session trying to authorize: %s\n", session.AccessTokenID)
|
||||
web.Error(w, "no session found", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), types.UserIdKey, userClaims.Subject)
|
||||
ctx = context.WithValue(ctx, types.JTIKey, userClaims.ID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
@ -3,7 +3,6 @@ package oauth
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
@ -29,37 +28,17 @@ func (h *OAuthHandler) AuthorizeClient(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
client, err := h.repo.GetApiServiceCID(r.Context(), clientId)
|
||||
if err != nil {
|
||||
uri := fmt.Sprintf("%s?error=access_denied&error_description=Service+not+authorized", redirectUri)
|
||||
if state != "" {
|
||||
uri += "&state=" + state
|
||||
}
|
||||
scopes := strings.Split(strings.TrimSpace(r.URL.Query().Get("scope")), " ")
|
||||
|
||||
if uri, err := h.verifyOAuthClient(r.Context(), &VerifyOAuthClientParams{
|
||||
ClientID: clientId,
|
||||
RedirectURI: &redirectUri,
|
||||
State: state,
|
||||
Scopes: &scopes,
|
||||
}); err != nil {
|
||||
http.Redirect(w, r, uri, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
if !client.IsActive {
|
||||
uri := fmt.Sprintf("%s?error=temporarily_unavailable&error_description=Service+not+active", redirectUri)
|
||||
if state != "" {
|
||||
uri += "&state=" + state
|
||||
}
|
||||
http.Redirect(w, r, uri, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
scopes := strings.SplitSeq(strings.TrimSpace(r.URL.Query().Get("scope")), " ")
|
||||
|
||||
for scope := range scopes {
|
||||
if !slices.Contains(client.Scopes, scope) {
|
||||
uri := fmt.Sprintf("%s?error=invalid_scope&error_description=Scope+%s+is+not+allowed", redirectUri, strings.ReplaceAll(scope, " ", "+"))
|
||||
if state != "" {
|
||||
uri += "&state=" + state
|
||||
}
|
||||
http.Redirect(w, r, uri, http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/auth?%s", r.URL.Query().Encode()), http.StatusFound)
|
||||
}
|
||||
|
58
internal/oauth/client.go
Normal file
58
internal/oauth/client.go
Normal file
@ -0,0 +1,58 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type VerifyOAuthClientParams struct {
|
||||
ClientID string `json:"client_id"`
|
||||
RedirectURI *string `json:"redirect_uri"`
|
||||
State string `json:"state"`
|
||||
Scopes *[]string `json:"scopes"`
|
||||
}
|
||||
|
||||
func (h *OAuthHandler) verifyOAuthClient(ctx context.Context, params *VerifyOAuthClientParams) (string, error) {
|
||||
client, err := h.repo.GetApiServiceCID(ctx, params.ClientID)
|
||||
if err != nil {
|
||||
uri := fmt.Sprintf("%s?error=access_denied&error_description=Service+not+authorized", *params.RedirectURI)
|
||||
if params.State != "" {
|
||||
uri += "&state=" + params.State
|
||||
}
|
||||
return uri, fmt.Errorf("target oauth service with client id '%s' is not registered", params.ClientID)
|
||||
}
|
||||
|
||||
if !client.IsActive {
|
||||
uri := fmt.Sprintf("%s?error=temporarily_unavailable&error_description=Service+not+active", *params.RedirectURI)
|
||||
if params.State != "" {
|
||||
uri += "&state=" + params.State
|
||||
}
|
||||
return uri, fmt.Errorf("target oauth service with client id '%s' is not available", client.ClientID)
|
||||
}
|
||||
|
||||
if params.Scopes != nil {
|
||||
for _, scope := range *params.Scopes {
|
||||
if !slices.Contains(client.Scopes, scope) {
|
||||
uri := fmt.Sprintf("%s?error=invalid_scope&error_description=Scope+%s+is+not+allowed", *params.RedirectURI, strings.ReplaceAll(scope, " ", "+"))
|
||||
if params.State != "" {
|
||||
uri += "&state=" + params.State
|
||||
}
|
||||
return uri, fmt.Errorf("unallowed scope '%s' requested", scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if params.RedirectURI != nil {
|
||||
if !slices.Contains(client.RedirectUris, *params.RedirectURI) {
|
||||
uri := fmt.Sprintf("%s?error=invalid_request&error_description=Redirect+URI+is+not+allowed", *params.RedirectURI)
|
||||
if params.State != "" {
|
||||
uri += "&state=" + params.State
|
||||
}
|
||||
return uri, fmt.Errorf("redirect uri '%s' is unallowed", *params.RedirectURI)
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
@ -1,10 +1,13 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/cache"
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
"github.com/google/uuid"
|
||||
@ -25,6 +28,7 @@ func (h *OAuthHandler) getAuthCode(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
type Request struct {
|
||||
Nonce string `json:"nonce"`
|
||||
ClientID string `json:"client_id"`
|
||||
}
|
||||
|
||||
var req Request
|
||||
@ -35,7 +39,39 @@ func (h *OAuthHandler) getAuthCode(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Create real authorization code
|
||||
if _, err := h.verifyOAuthClient(r.Context(), &VerifyOAuthClientParams{
|
||||
ClientID: req.ClientID,
|
||||
RedirectURI: nil,
|
||||
State: "",
|
||||
Scopes: nil,
|
||||
}); err != nil {
|
||||
web.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
buf := make([]byte, 32)
|
||||
_, err = rand.Read(buf)
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to generate auth code:", err)
|
||||
web.Error(w, "failed to create authorization code", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
authCode := base64.RawURLEncoding.EncodeToString(buf)
|
||||
|
||||
params := cache.SaveAuthCodeParams{
|
||||
AuthCode: authCode,
|
||||
UserID: user.ID.String(),
|
||||
ClientID: req.ClientID,
|
||||
Nonce: req.Nonce,
|
||||
}
|
||||
|
||||
log.Printf("DEBUG: Saving auth code session with params: %#v\n", params)
|
||||
|
||||
if err := h.cache.SaveAuthCode(r.Context(), ¶ms); err != nil {
|
||||
log.Println("ERR: Failed to save auth code in redis:", err)
|
||||
web.Error(w, "failed to generate auth code", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Code string `json:"code"`
|
||||
@ -46,7 +82,7 @@ func (h *OAuthHandler) getAuthCode(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(Response{
|
||||
Code: fmt.Sprintf("%s,%s", user.ID.String(), req.Nonce),
|
||||
Code: authCode,
|
||||
}); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
|
@ -14,6 +14,7 @@ func (h *OAuthHandler) OpenIdConfiguration(w http.ResponseWriter, r *http.Reques
|
||||
JwksURI string `json:"jwks_uri"`
|
||||
Issuer string `json:"issuer"`
|
||||
EndSessionEndpoint string `json:"end_session_endpoint"`
|
||||
GrantTypesSupported []string `json:"grant_types_supported"`
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@ -28,6 +29,10 @@ func (h *OAuthHandler) OpenIdConfiguration(w http.ResponseWriter, r *http.Reques
|
||||
JwksURI: h.cfg.Uri + "/.well-known/jwks.json",
|
||||
Issuer: h.cfg.Uri,
|
||||
EndSessionEndpoint: h.cfg.Uri + "/api/v1/oauth/logout",
|
||||
GrantTypesSupported: []string{
|
||||
"authorization_code",
|
||||
"refresh_token",
|
||||
},
|
||||
}); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"gitea.local/admin/hspguard/internal/cache"
|
||||
"gitea.local/admin/hspguard/internal/config"
|
||||
imiddleware "gitea.local/admin/hspguard/internal/middleware"
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
@ -9,12 +10,14 @@ import (
|
||||
|
||||
type OAuthHandler struct {
|
||||
repo *repository.Queries
|
||||
cache *cache.Client
|
||||
cfg *config.AppConfig
|
||||
}
|
||||
|
||||
func NewOAuthHandler(repo *repository.Queries, cfg *config.AppConfig) *OAuthHandler {
|
||||
func NewOAuthHandler(repo *repository.Queries, cache *cache.Client, cfg *config.AppConfig) *OAuthHandler {
|
||||
return &OAuthHandler{
|
||||
repo,
|
||||
cache,
|
||||
cfg,
|
||||
}
|
||||
}
|
||||
@ -22,7 +25,7 @@ func NewOAuthHandler(repo *repository.Queries, cfg *config.AppConfig) *OAuthHand
|
||||
func (h *OAuthHandler) RegisterRoutes(router chi.Router) {
|
||||
router.Route("/oauth", func(r chi.Router) {
|
||||
r.Group(func(protected chi.Router) {
|
||||
authMiddleware := imiddleware.NewAuthMiddleware(h.cfg)
|
||||
authMiddleware := imiddleware.NewAuthMiddleware(h.cfg, h.repo)
|
||||
protected.Use(authMiddleware.Runner)
|
||||
|
||||
protected.Post("/code", h.getAuthCode)
|
||||
|
@ -5,10 +5,12 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"gitea.local/admin/hspguard/internal/types"
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
@ -16,6 +18,84 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (h *OAuthHandler) signApiTokens(user *repository.User, apiService *repository.ApiService, nonce *string) (*types.SignedToken, *types.SignedToken, *types.SignedToken, error) {
|
||||
accessExpiresIn := 15 * time.Minute
|
||||
accessExpiresAt := time.Now().Add(accessExpiresIn)
|
||||
accessJTI := uuid.New()
|
||||
|
||||
accessClaims := types.ApiClaims{
|
||||
Permissions: []string{},
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: h.cfg.Uri,
|
||||
Subject: apiService.ClientID,
|
||||
Audience: jwt.ClaimStrings{apiService.ClientID},
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(accessExpiresAt),
|
||||
ID: accessJTI.String(),
|
||||
},
|
||||
}
|
||||
|
||||
access, err := util.SignJwtToken(accessClaims, h.cfg.Jwt.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
var roles = []string{"user"}
|
||||
|
||||
if user.IsAdmin {
|
||||
roles = append(roles, "admin")
|
||||
}
|
||||
|
||||
idExpiresIn := 15 * time.Minute
|
||||
idExpiresAt := time.Now().Add(idExpiresIn)
|
||||
idJTI := uuid.New()
|
||||
|
||||
idClaims := types.IdTokenClaims{
|
||||
Email: user.Email,
|
||||
EmailVerified: user.EmailVerified,
|
||||
Name: user.FullName,
|
||||
Picture: user.ProfilePicture,
|
||||
Nonce: nonce,
|
||||
Roles: roles,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: h.cfg.Uri,
|
||||
Subject: user.ID.String(),
|
||||
Audience: jwt.ClaimStrings{apiService.ClientID},
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(idExpiresAt),
|
||||
ID: idJTI.String(),
|
||||
},
|
||||
}
|
||||
|
||||
idToken, err := util.SignJwtToken(idClaims, h.cfg.Jwt.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
refreshExpiresIn := 24 * time.Hour
|
||||
refreshExpiresAt := time.Now().Add(refreshExpiresIn)
|
||||
refreshJTI := uuid.New()
|
||||
|
||||
refreshClaims := types.ApiRefreshClaims{
|
||||
UserID: user.ID.String(),
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: h.cfg.Uri,
|
||||
Subject: apiService.ClientID,
|
||||
Audience: jwt.ClaimStrings{apiService.ClientID},
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(refreshExpiresAt),
|
||||
ID: refreshJTI.String(),
|
||||
},
|
||||
}
|
||||
|
||||
refresh, err := util.SignJwtToken(refreshClaims, h.cfg.Jwt.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
return types.NewSignedToken(idToken, idExpiresAt, idJTI), types.NewSignedToken(access, accessExpiresAt, accessJTI), types.NewSignedToken(refresh, refreshExpiresAt, refreshJTI), nil
|
||||
}
|
||||
|
||||
func (h *OAuthHandler) tokenEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||
log.Println("[OAUTH] New request to token endpoint")
|
||||
|
||||
@ -55,75 +135,205 @@ func (h *OAuthHandler) tokenEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
grantType := r.FormValue("grant_type")
|
||||
redirectUri := r.FormValue("redirect_uri")
|
||||
|
||||
log.Printf("Redirect URI is %s\n", redirectUri)
|
||||
log.Println("DEBUG: Verifying target oauth client before proceeding...")
|
||||
|
||||
if _, err := h.verifyOAuthClient(r.Context(), &VerifyOAuthClientParams{
|
||||
ClientID: clientId,
|
||||
RedirectURI: nil,
|
||||
State: "",
|
||||
Scopes: nil,
|
||||
}); err != nil {
|
||||
web.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
switch grantType {
|
||||
case "authorization_code":
|
||||
redirectUri := r.FormValue("redirect_uri")
|
||||
log.Printf("Redirect URI is %s\n", redirectUri)
|
||||
|
||||
code := r.FormValue("code")
|
||||
|
||||
fmt.Printf("Code received: %s\n", code)
|
||||
|
||||
// TODO: Verify code from another db table
|
||||
nonce := strings.Split(code, ",")[1]
|
||||
codeSession, err := h.cache.GetAuthCode(r.Context(), code)
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to find session under the code %s: %v\n", code, err)
|
||||
web.Error(w, "no session found under this auth code", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
userId := strings.Split(code, ",")[0]
|
||||
log.Printf("DEBUG: Fetched code session: %#v\n", codeSession)
|
||||
|
||||
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(userId))
|
||||
apiService, err := h.repo.GetApiServiceCID(r.Context(), codeSession.ClientID)
|
||||
if err != nil {
|
||||
log.Printf("ERR: Could not find API service with client %s: %v\n", codeSession.ClientID, err)
|
||||
web.Error(w, "service is not registered", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if codeSession.ClientID != clientId {
|
||||
web.Error(w, "invalid auth", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(codeSession.UserID))
|
||||
if err != nil {
|
||||
web.Error(w, "requested user not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var roles = []string{"user"}
|
||||
|
||||
if user.IsAdmin {
|
||||
roles = append(roles, "admin")
|
||||
}
|
||||
|
||||
claims := types.ApiClaims{
|
||||
Email: user.Email,
|
||||
// TODO:
|
||||
EmailVerified: true,
|
||||
Name: user.FullName,
|
||||
Picture: user.ProfilePicture,
|
||||
Nonce: nonce,
|
||||
Roles: roles,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: h.cfg.Uri,
|
||||
// TODO: use dedicated API id that is in local DB and bind to user there
|
||||
Subject: user.ID.String(),
|
||||
Audience: jwt.ClaimStrings{clientId},
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
|
||||
},
|
||||
}
|
||||
|
||||
idToken, err := util.SignJwtToken(claims, h.cfg.Jwt.PrivateKey)
|
||||
id, access, refresh, err := h.signApiTokens(&user, &apiService, &codeSession.Nonce)
|
||||
if err != nil {
|
||||
web.Error(w, "failed to sign id token", http.StatusInternalServerError)
|
||||
log.Println("ERR: Failed to sign api tokens:", err)
|
||||
web.Error(w, "failed to sign tokens", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("DEBUG: Created api tokens: %v\n\n%v\n\n%v\n", id.ID.String(), access.ID.String(), refresh.ID.String())
|
||||
|
||||
userId, err := uuid.Parse(codeSession.UserID)
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to parse user '%s' uuid: %v\n", codeSession.UserID, err)
|
||||
web.Error(w, "failed to sign tokens", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
ipAddr := util.GetClientIP(r)
|
||||
ua := r.UserAgent()
|
||||
|
||||
session, err := h.repo.CreateServiceSession(r.Context(), repository.CreateServiceSessionParams{
|
||||
ServiceID: apiService.ID,
|
||||
ClientID: apiService.ClientID,
|
||||
UserID: &userId,
|
||||
ExpiresAt: &refresh.ExpiresAt,
|
||||
LastActive: nil,
|
||||
IpAddress: &ipAddr,
|
||||
UserAgent: &ua,
|
||||
AccessTokenID: &access.ID,
|
||||
RefreshTokenID: &refresh.ID,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to create new service session: %v\n", err)
|
||||
web.Error(w, "failed to create session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("INFO: Service session created for '%s' client_id with '%s' id\n", apiService.ClientID, session.ID.String())
|
||||
|
||||
type Response struct {
|
||||
IdToken string `json:"id_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
AccessToken string `json:"access_token"`
|
||||
Email string `json:"email"`
|
||||
// TODO: add expires_in, refresh_token, scope (RFC 8693 $2)
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn float64 `json:"expires_in"`
|
||||
// TODO: add scope (RFC 8693 $2)
|
||||
}
|
||||
|
||||
response := Response{
|
||||
IdToken: idToken,
|
||||
IdToken: id.Token,
|
||||
TokenType: "Bearer",
|
||||
// FIXME:
|
||||
AccessToken: idToken,
|
||||
AccessToken: access.Token,
|
||||
RefreshToken: refresh.Token,
|
||||
ExpiresIn: math.Ceil(access.ExpiresAt.Sub(time.Now()).Seconds()),
|
||||
Email: user.Email,
|
||||
}
|
||||
|
||||
log.Printf("sending following response: %#v\n", response)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
encoder := json.NewEncoder(w)
|
||||
if err := encoder.Encode(response); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
case "refresh_token":
|
||||
refreshToken := r.FormValue("refresh_token")
|
||||
|
||||
var claims types.ApiRefreshClaims
|
||||
|
||||
token, err := util.VerifyToken(refreshToken, h.cfg.Jwt.PublicKey, &claims)
|
||||
if err != nil || !token.Valid {
|
||||
http.Error(w, fmt.Sprintf("invalid token: %v", err), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
expire, err := claims.GetExpirationTime()
|
||||
if err != nil {
|
||||
web.Error(w, "failed to retrieve enough info from the token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if time.Now().After(expire.Time) {
|
||||
web.Error(w, "token is expired", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
refreshJTI, err := uuid.Parse(claims.ID)
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to parse refresh token JTI as uuid: %v\n", err)
|
||||
web.Error(w, "failed to refresh token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
session, err := h.repo.GetServiceSessionByRefreshJTI(r.Context(), &refreshJTI)
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to find session by '%s' refresh jti: %v\n", refreshJTI.String(), err)
|
||||
web.Error(w, "session invalid", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if !session.IsActive {
|
||||
log.Printf("INFO: Session with id '%s' is not active", session.ID.String())
|
||||
web.Error(w, "session ended", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := uuid.Parse(claims.UserID)
|
||||
if err != nil {
|
||||
web.Error(w, "invalid user credentials in refresh token", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
user, err := h.repo.FindUserId(r.Context(), userID)
|
||||
|
||||
apiService, err := h.repo.GetApiServiceCID(r.Context(), claims.Subject)
|
||||
if err != nil {
|
||||
web.Error(w, "api service is not registered", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
id, access, refresh, err := h.signApiTokens(&user, &apiService, nil)
|
||||
|
||||
if err := h.repo.UpdateServiceSessionTokens(r.Context(), repository.UpdateServiceSessionTokensParams{
|
||||
ID: session.ID,
|
||||
AccessTokenID: &access.ID,
|
||||
RefreshTokenID: &refresh.ID,
|
||||
ExpiresAt: &refresh.ExpiresAt,
|
||||
}); err != nil {
|
||||
log.Printf("ERR: Failed to update service session with '%s' id: %v\n", session.ID.String(), err)
|
||||
web.Error(w, "failed to update session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
IdToken string `json:"id_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn float64 `json:"expires_in"`
|
||||
}
|
||||
|
||||
response := Response{
|
||||
IdToken: id.Token,
|
||||
TokenType: "Bearer",
|
||||
AccessToken: access.Token,
|
||||
RefreshToken: refresh.Token,
|
||||
ExpiresIn: math.Ceil(access.ExpiresAt.Sub(time.Now()).Seconds()),
|
||||
}
|
||||
|
||||
log.Printf("DEBUG: refresh - sending following response: %#v\n", response)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
encoder := json.NewEncoder(w)
|
||||
if err := encoder.Encode(response); err != nil {
|
||||
|
@ -28,7 +28,7 @@ INSERT INTO api_services (
|
||||
client_id, client_secret, name, description, redirect_uris, scopes, grant_types, is_active
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8
|
||||
) RETURNING id, client_id, client_secret, name, redirect_uris, scopes, grant_types, created_at, updated_at, is_active, description
|
||||
) RETURNING id, client_id, client_secret, name, redirect_uris, scopes, grant_types, created_at, updated_at, is_active, description, icon_url
|
||||
`
|
||||
|
||||
type CreateApiServiceParams struct {
|
||||
@ -66,6 +66,7 @@ func (q *Queries) CreateApiService(ctx context.Context, arg CreateApiServicePara
|
||||
&i.UpdatedAt,
|
||||
&i.IsActive,
|
||||
&i.Description,
|
||||
&i.IconUrl,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@ -83,7 +84,7 @@ func (q *Queries) DeactivateApiService(ctx context.Context, clientID string) err
|
||||
}
|
||||
|
||||
const getApiServiceCID = `-- name: GetApiServiceCID :one
|
||||
SELECT id, client_id, client_secret, name, redirect_uris, scopes, grant_types, created_at, updated_at, is_active, description FROM api_services
|
||||
SELECT id, client_id, client_secret, name, redirect_uris, scopes, grant_types, created_at, updated_at, is_active, description, icon_url FROM api_services
|
||||
WHERE client_id = $1
|
||||
AND is_active = true
|
||||
LIMIT 1
|
||||
@ -104,12 +105,13 @@ func (q *Queries) GetApiServiceCID(ctx context.Context, clientID string) (ApiSer
|
||||
&i.UpdatedAt,
|
||||
&i.IsActive,
|
||||
&i.Description,
|
||||
&i.IconUrl,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getApiServiceId = `-- name: GetApiServiceId :one
|
||||
SELECT id, client_id, client_secret, name, redirect_uris, scopes, grant_types, created_at, updated_at, is_active, description FROM api_services
|
||||
SELECT id, client_id, client_secret, name, redirect_uris, scopes, grant_types, created_at, updated_at, is_active, description, icon_url FROM api_services
|
||||
WHERE id = $1
|
||||
LIMIT 1
|
||||
`
|
||||
@ -129,12 +131,13 @@ func (q *Queries) GetApiServiceId(ctx context.Context, id uuid.UUID) (ApiService
|
||||
&i.UpdatedAt,
|
||||
&i.IsActive,
|
||||
&i.Description,
|
||||
&i.IconUrl,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listApiServices = `-- name: ListApiServices :many
|
||||
SELECT id, client_id, client_secret, name, redirect_uris, scopes, grant_types, created_at, updated_at, is_active, description FROM api_services
|
||||
SELECT id, client_id, client_secret, name, redirect_uris, scopes, grant_types, created_at, updated_at, is_active, description, icon_url FROM api_services
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
@ -159,6 +162,7 @@ func (q *Queries) ListApiServices(ctx context.Context) ([]ApiService, error) {
|
||||
&i.UpdatedAt,
|
||||
&i.IsActive,
|
||||
&i.Description,
|
||||
&i.IconUrl,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -180,7 +184,7 @@ SET
|
||||
grant_types = $6,
|
||||
updated_at = NOW()
|
||||
WHERE client_id = $1
|
||||
RETURNING id, client_id, client_secret, name, redirect_uris, scopes, grant_types, created_at, updated_at, is_active, description
|
||||
RETURNING id, client_id, client_secret, name, redirect_uris, scopes, grant_types, created_at, updated_at, is_active, description, icon_url
|
||||
`
|
||||
|
||||
type UpdateApiServiceParams struct {
|
||||
@ -214,6 +218,7 @@ func (q *Queries) UpdateApiService(ctx context.Context, arg UpdateApiServicePara
|
||||
&i.UpdatedAt,
|
||||
&i.IsActive,
|
||||
&i.Description,
|
||||
&i.IconUrl,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
@ -22,6 +22,25 @@ type ApiService struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Description *string `json:"description"`
|
||||
IconUrl *string `json:"icon_url"`
|
||||
}
|
||||
|
||||
type ServiceSession struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ServiceID uuid.UUID `json:"service_id"`
|
||||
ClientID string `json:"client_id"`
|
||||
UserID *uuid.UUID `json:"user_id"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
LastActive *time.Time `json:"last_active"`
|
||||
IpAddress *string `json:"ip_address"`
|
||||
UserAgent *string `json:"user_agent"`
|
||||
AccessTokenID *uuid.UUID `json:"access_token_id"`
|
||||
RefreshTokenID *uuid.UUID `json:"refresh_token_id"`
|
||||
IsActive bool `json:"is_active"`
|
||||
RevokedAt *time.Time `json:"revoked_at"`
|
||||
Scope *string `json:"scope"`
|
||||
Claims []byte `json:"claims"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
@ -40,3 +59,19 @@ type User struct {
|
||||
AvatarVerified bool `json:"avatar_verified"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
type UserSession struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
SessionType string `json:"session_type"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
LastActive *time.Time `json:"last_active"`
|
||||
IpAddress *string `json:"ip_address"`
|
||||
UserAgent *string `json:"user_agent"`
|
||||
AccessTokenID *uuid.UUID `json:"access_token_id"`
|
||||
RefreshTokenID *uuid.UUID `json:"refresh_token_id"`
|
||||
DeviceInfo []byte `json:"device_info"`
|
||||
IsActive bool `json:"is_active"`
|
||||
RevokedAt *time.Time `json:"revoked_at"`
|
||||
}
|
||||
|
419
internal/repository/service_sessions.sql.go
Normal file
419
internal/repository/service_sessions.sql.go
Normal file
@ -0,0 +1,419 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: service_sessions.sql
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const createServiceSession = `-- name: CreateServiceSession :one
|
||||
INSERT INTO service_sessions (
|
||||
service_id, client_id, user_id, issued_at, expires_at, last_active,
|
||||
ip_address, user_agent, access_token_id, refresh_token_id,
|
||||
is_active, scope, claims
|
||||
) VALUES (
|
||||
$1, $2, $3, NOW(), $4, $5,
|
||||
$6, $7, $8, $9,
|
||||
TRUE, $10, $11
|
||||
)
|
||||
RETURNING id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims
|
||||
`
|
||||
|
||||
type CreateServiceSessionParams struct {
|
||||
ServiceID uuid.UUID `json:"service_id"`
|
||||
ClientID string `json:"client_id"`
|
||||
UserID *uuid.UUID `json:"user_id"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
LastActive *time.Time `json:"last_active"`
|
||||
IpAddress *string `json:"ip_address"`
|
||||
UserAgent *string `json:"user_agent"`
|
||||
AccessTokenID *uuid.UUID `json:"access_token_id"`
|
||||
RefreshTokenID *uuid.UUID `json:"refresh_token_id"`
|
||||
Scope *string `json:"scope"`
|
||||
Claims []byte `json:"claims"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateServiceSession(ctx context.Context, arg CreateServiceSessionParams) (ServiceSession, error) {
|
||||
row := q.db.QueryRow(ctx, createServiceSession,
|
||||
arg.ServiceID,
|
||||
arg.ClientID,
|
||||
arg.UserID,
|
||||
arg.ExpiresAt,
|
||||
arg.LastActive,
|
||||
arg.IpAddress,
|
||||
arg.UserAgent,
|
||||
arg.AccessTokenID,
|
||||
arg.RefreshTokenID,
|
||||
arg.Scope,
|
||||
arg.Claims,
|
||||
)
|
||||
var i ServiceSession
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ServiceID,
|
||||
&i.ClientID,
|
||||
&i.UserID,
|
||||
&i.IssuedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastActive,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.AccessTokenID,
|
||||
&i.RefreshTokenID,
|
||||
&i.IsActive,
|
||||
&i.RevokedAt,
|
||||
&i.Scope,
|
||||
&i.Claims,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getServiceSessionByAccessJTI = `-- name: GetServiceSessionByAccessJTI :one
|
||||
SELECT id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims FROM service_sessions
|
||||
WHERE access_token_id = $1
|
||||
AND is_active = TRUE
|
||||
`
|
||||
|
||||
func (q *Queries) GetServiceSessionByAccessJTI(ctx context.Context, accessTokenID *uuid.UUID) (ServiceSession, error) {
|
||||
row := q.db.QueryRow(ctx, getServiceSessionByAccessJTI, accessTokenID)
|
||||
var i ServiceSession
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ServiceID,
|
||||
&i.ClientID,
|
||||
&i.UserID,
|
||||
&i.IssuedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastActive,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.AccessTokenID,
|
||||
&i.RefreshTokenID,
|
||||
&i.IsActive,
|
||||
&i.RevokedAt,
|
||||
&i.Scope,
|
||||
&i.Claims,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getServiceSessionByRefreshJTI = `-- name: GetServiceSessionByRefreshJTI :one
|
||||
SELECT id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims FROM service_sessions
|
||||
WHERE refresh_token_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetServiceSessionByRefreshJTI(ctx context.Context, refreshTokenID *uuid.UUID) (ServiceSession, error) {
|
||||
row := q.db.QueryRow(ctx, getServiceSessionByRefreshJTI, refreshTokenID)
|
||||
var i ServiceSession
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ServiceID,
|
||||
&i.ClientID,
|
||||
&i.UserID,
|
||||
&i.IssuedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastActive,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.AccessTokenID,
|
||||
&i.RefreshTokenID,
|
||||
&i.IsActive,
|
||||
&i.RevokedAt,
|
||||
&i.Scope,
|
||||
&i.Claims,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getServiceSessions = `-- name: GetServiceSessions :many
|
||||
SELECT session.id, session.service_id, session.client_id, session.user_id, session.issued_at, session.expires_at, session.last_active, session.ip_address, session.user_agent, session.access_token_id, session.refresh_token_id, session.is_active, session.revoked_at, session.scope, session.claims, service.id, service.client_id, service.client_secret, service.name, service.redirect_uris, service.scopes, service.grant_types, service.created_at, service.updated_at, service.is_active, service.description, service.icon_url, u.id, u.email, u.full_name, u.password_hash, u.is_admin, u.created_at, u.updated_at, u.last_login, u.phone_number, u.profile_picture, u.created_by, u.email_verified, u.avatar_verified, u.verified
|
||||
FROM service_sessions AS session
|
||||
JOIN api_services AS service ON service.id = session.service_id
|
||||
JOIN users AS u ON u.id = session.user_id
|
||||
ORDER BY session.issued_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`
|
||||
|
||||
type GetServiceSessionsParams struct {
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
type GetServiceSessionsRow struct {
|
||||
ServiceSession ServiceSession `json:"service_session"`
|
||||
ApiService ApiService `json:"api_service"`
|
||||
User User `json:"user"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetServiceSessions(ctx context.Context, arg GetServiceSessionsParams) ([]GetServiceSessionsRow, error) {
|
||||
rows, err := q.db.Query(ctx, getServiceSessions, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetServiceSessionsRow
|
||||
for rows.Next() {
|
||||
var i GetServiceSessionsRow
|
||||
if err := rows.Scan(
|
||||
&i.ServiceSession.ID,
|
||||
&i.ServiceSession.ServiceID,
|
||||
&i.ServiceSession.ClientID,
|
||||
&i.ServiceSession.UserID,
|
||||
&i.ServiceSession.IssuedAt,
|
||||
&i.ServiceSession.ExpiresAt,
|
||||
&i.ServiceSession.LastActive,
|
||||
&i.ServiceSession.IpAddress,
|
||||
&i.ServiceSession.UserAgent,
|
||||
&i.ServiceSession.AccessTokenID,
|
||||
&i.ServiceSession.RefreshTokenID,
|
||||
&i.ServiceSession.IsActive,
|
||||
&i.ServiceSession.RevokedAt,
|
||||
&i.ServiceSession.Scope,
|
||||
&i.ServiceSession.Claims,
|
||||
&i.ApiService.ID,
|
||||
&i.ApiService.ClientID,
|
||||
&i.ApiService.ClientSecret,
|
||||
&i.ApiService.Name,
|
||||
&i.ApiService.RedirectUris,
|
||||
&i.ApiService.Scopes,
|
||||
&i.ApiService.GrantTypes,
|
||||
&i.ApiService.CreatedAt,
|
||||
&i.ApiService.UpdatedAt,
|
||||
&i.ApiService.IsActive,
|
||||
&i.ApiService.Description,
|
||||
&i.ApiService.IconUrl,
|
||||
&i.User.ID,
|
||||
&i.User.Email,
|
||||
&i.User.FullName,
|
||||
&i.User.PasswordHash,
|
||||
&i.User.IsAdmin,
|
||||
&i.User.CreatedAt,
|
||||
&i.User.UpdatedAt,
|
||||
&i.User.LastLogin,
|
||||
&i.User.PhoneNumber,
|
||||
&i.User.ProfilePicture,
|
||||
&i.User.CreatedBy,
|
||||
&i.User.EmailVerified,
|
||||
&i.User.AvatarVerified,
|
||||
&i.User.Verified,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getServiceSessionsCount = `-- name: GetServiceSessionsCount :one
|
||||
SELECT COUNT(*) FROM service_sessions
|
||||
`
|
||||
|
||||
func (q *Queries) GetServiceSessionsCount(ctx context.Context) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, getServiceSessionsCount)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const listActiveServiceSessionsByClient = `-- name: ListActiveServiceSessionsByClient :many
|
||||
SELECT id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims FROM service_sessions
|
||||
WHERE client_id = $1
|
||||
AND is_active = TRUE
|
||||
ORDER BY issued_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`
|
||||
|
||||
type ListActiveServiceSessionsByClientParams struct {
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListActiveServiceSessionsByClient(ctx context.Context, arg ListActiveServiceSessionsByClientParams) ([]ServiceSession, error) {
|
||||
rows, err := q.db.Query(ctx, listActiveServiceSessionsByClient, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ServiceSession
|
||||
for rows.Next() {
|
||||
var i ServiceSession
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ServiceID,
|
||||
&i.ClientID,
|
||||
&i.UserID,
|
||||
&i.IssuedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastActive,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.AccessTokenID,
|
||||
&i.RefreshTokenID,
|
||||
&i.IsActive,
|
||||
&i.RevokedAt,
|
||||
&i.Scope,
|
||||
&i.Claims,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listActiveServiceSessionsByUser = `-- name: ListActiveServiceSessionsByUser :many
|
||||
SELECT id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims FROM service_sessions
|
||||
WHERE user_id = $1
|
||||
AND is_active = TRUE
|
||||
ORDER BY issued_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`
|
||||
|
||||
type ListActiveServiceSessionsByUserParams struct {
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListActiveServiceSessionsByUser(ctx context.Context, arg ListActiveServiceSessionsByUserParams) ([]ServiceSession, error) {
|
||||
rows, err := q.db.Query(ctx, listActiveServiceSessionsByUser, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ServiceSession
|
||||
for rows.Next() {
|
||||
var i ServiceSession
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ServiceID,
|
||||
&i.ClientID,
|
||||
&i.UserID,
|
||||
&i.IssuedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastActive,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.AccessTokenID,
|
||||
&i.RefreshTokenID,
|
||||
&i.IsActive,
|
||||
&i.RevokedAt,
|
||||
&i.Scope,
|
||||
&i.Claims,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listAllServiceSessions = `-- name: ListAllServiceSessions :many
|
||||
SELECT id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims FROM service_sessions
|
||||
ORDER BY issued_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`
|
||||
|
||||
type ListAllServiceSessionsParams struct {
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListAllServiceSessions(ctx context.Context, arg ListAllServiceSessionsParams) ([]ServiceSession, error) {
|
||||
rows, err := q.db.Query(ctx, listAllServiceSessions, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ServiceSession
|
||||
for rows.Next() {
|
||||
var i ServiceSession
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ServiceID,
|
||||
&i.ClientID,
|
||||
&i.UserID,
|
||||
&i.IssuedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastActive,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.AccessTokenID,
|
||||
&i.RefreshTokenID,
|
||||
&i.IsActive,
|
||||
&i.RevokedAt,
|
||||
&i.Scope,
|
||||
&i.Claims,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const revokeServiceSession = `-- name: RevokeServiceSession :exec
|
||||
UPDATE service_sessions
|
||||
SET is_active = FALSE,
|
||||
revoked_at = NOW()
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE
|
||||
`
|
||||
|
||||
func (q *Queries) RevokeServiceSession(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, revokeServiceSession, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateServiceSessionLastActive = `-- name: UpdateServiceSessionLastActive :exec
|
||||
UPDATE service_sessions
|
||||
SET last_active = NOW()
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE
|
||||
`
|
||||
|
||||
func (q *Queries) UpdateServiceSessionLastActive(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, updateServiceSessionLastActive, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateServiceSessionTokens = `-- name: UpdateServiceSessionTokens :exec
|
||||
UPDATE service_sessions
|
||||
SET access_token_id = $2, refresh_token_id = $3, expires_at = $4
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE
|
||||
`
|
||||
|
||||
type UpdateServiceSessionTokensParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
AccessTokenID *uuid.UUID `json:"access_token_id"`
|
||||
RefreshTokenID *uuid.UUID `json:"refresh_token_id"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateServiceSessionTokens(ctx context.Context, arg UpdateServiceSessionTokensParams) error {
|
||||
_, err := q.db.Exec(ctx, updateServiceSessionTokens,
|
||||
arg.ID,
|
||||
arg.AccessTokenID,
|
||||
arg.RefreshTokenID,
|
||||
arg.ExpiresAt,
|
||||
)
|
||||
return err
|
||||
}
|
334
internal/repository/user_sessions.sql.go
Normal file
334
internal/repository/user_sessions.sql.go
Normal file
@ -0,0 +1,334 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: user_sessions.sql
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const createUserSession = `-- name: CreateUserSession :one
|
||||
INSERT INTO user_sessions (
|
||||
user_id, session_type, issued_at, expires_at, last_active,
|
||||
ip_address, user_agent, access_token_id, refresh_token_id,
|
||||
device_info, is_active
|
||||
) VALUES (
|
||||
$1, $2, NOW(), $3, $4,
|
||||
$5, $6, $7, $8,
|
||||
$9, TRUE
|
||||
)
|
||||
RETURNING id, user_id, session_type, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, device_info, is_active, revoked_at
|
||||
`
|
||||
|
||||
type CreateUserSessionParams struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
SessionType string `json:"session_type"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
LastActive *time.Time `json:"last_active"`
|
||||
IpAddress *string `json:"ip_address"`
|
||||
UserAgent *string `json:"user_agent"`
|
||||
AccessTokenID *uuid.UUID `json:"access_token_id"`
|
||||
RefreshTokenID *uuid.UUID `json:"refresh_token_id"`
|
||||
DeviceInfo []byte `json:"device_info"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateUserSession(ctx context.Context, arg CreateUserSessionParams) (UserSession, error) {
|
||||
row := q.db.QueryRow(ctx, createUserSession,
|
||||
arg.UserID,
|
||||
arg.SessionType,
|
||||
arg.ExpiresAt,
|
||||
arg.LastActive,
|
||||
arg.IpAddress,
|
||||
arg.UserAgent,
|
||||
arg.AccessTokenID,
|
||||
arg.RefreshTokenID,
|
||||
arg.DeviceInfo,
|
||||
)
|
||||
var i UserSession
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.SessionType,
|
||||
&i.IssuedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastActive,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.AccessTokenID,
|
||||
&i.RefreshTokenID,
|
||||
&i.DeviceInfo,
|
||||
&i.IsActive,
|
||||
&i.RevokedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserSessionByAccessJTI = `-- name: GetUserSessionByAccessJTI :one
|
||||
SELECT id, user_id, session_type, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, device_info, is_active, revoked_at FROM user_sessions
|
||||
WHERE access_token_id = $1
|
||||
AND is_active = TRUE
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserSessionByAccessJTI(ctx context.Context, accessTokenID *uuid.UUID) (UserSession, error) {
|
||||
row := q.db.QueryRow(ctx, getUserSessionByAccessJTI, accessTokenID)
|
||||
var i UserSession
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.SessionType,
|
||||
&i.IssuedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastActive,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.AccessTokenID,
|
||||
&i.RefreshTokenID,
|
||||
&i.DeviceInfo,
|
||||
&i.IsActive,
|
||||
&i.RevokedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserSessionByRefreshJTI = `-- name: GetUserSessionByRefreshJTI :one
|
||||
SELECT id, user_id, session_type, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, device_info, is_active, revoked_at FROM user_sessions
|
||||
WHERE refresh_token_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserSessionByRefreshJTI(ctx context.Context, refreshTokenID *uuid.UUID) (UserSession, error) {
|
||||
row := q.db.QueryRow(ctx, getUserSessionByRefreshJTI, refreshTokenID)
|
||||
var i UserSession
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.SessionType,
|
||||
&i.IssuedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastActive,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.AccessTokenID,
|
||||
&i.RefreshTokenID,
|
||||
&i.DeviceInfo,
|
||||
&i.IsActive,
|
||||
&i.RevokedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserSessions = `-- name: GetUserSessions :many
|
||||
SELECT session.id, session.user_id, session.session_type, session.issued_at, session.expires_at, session.last_active, session.ip_address, session.user_agent, session.access_token_id, session.refresh_token_id, session.device_info, session.is_active, session.revoked_at, u.id, u.email, u.full_name, u.password_hash, u.is_admin, u.created_at, u.updated_at, u.last_login, u.phone_number, u.profile_picture, u.created_by, u.email_verified, u.avatar_verified, u.verified
|
||||
FROM user_sessions AS session
|
||||
JOIN users AS u ON u.id = session.user_id
|
||||
ORDER BY session.issued_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`
|
||||
|
||||
type GetUserSessionsParams struct {
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
type GetUserSessionsRow struct {
|
||||
UserSession UserSession `json:"user_session"`
|
||||
User User `json:"user"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetUserSessions(ctx context.Context, arg GetUserSessionsParams) ([]GetUserSessionsRow, error) {
|
||||
rows, err := q.db.Query(ctx, getUserSessions, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetUserSessionsRow
|
||||
for rows.Next() {
|
||||
var i GetUserSessionsRow
|
||||
if err := rows.Scan(
|
||||
&i.UserSession.ID,
|
||||
&i.UserSession.UserID,
|
||||
&i.UserSession.SessionType,
|
||||
&i.UserSession.IssuedAt,
|
||||
&i.UserSession.ExpiresAt,
|
||||
&i.UserSession.LastActive,
|
||||
&i.UserSession.IpAddress,
|
||||
&i.UserSession.UserAgent,
|
||||
&i.UserSession.AccessTokenID,
|
||||
&i.UserSession.RefreshTokenID,
|
||||
&i.UserSession.DeviceInfo,
|
||||
&i.UserSession.IsActive,
|
||||
&i.UserSession.RevokedAt,
|
||||
&i.User.ID,
|
||||
&i.User.Email,
|
||||
&i.User.FullName,
|
||||
&i.User.PasswordHash,
|
||||
&i.User.IsAdmin,
|
||||
&i.User.CreatedAt,
|
||||
&i.User.UpdatedAt,
|
||||
&i.User.LastLogin,
|
||||
&i.User.PhoneNumber,
|
||||
&i.User.ProfilePicture,
|
||||
&i.User.CreatedBy,
|
||||
&i.User.EmailVerified,
|
||||
&i.User.AvatarVerified,
|
||||
&i.User.Verified,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getUserSessionsCount = `-- name: GetUserSessionsCount :one
|
||||
SELECT COUNT(*) FROM user_sessions
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserSessionsCount(ctx context.Context) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, getUserSessionsCount)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const listActiveUserSessions = `-- name: ListActiveUserSessions :many
|
||||
SELECT id, user_id, session_type, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, device_info, is_active, revoked_at FROM user_sessions
|
||||
WHERE user_id = $1
|
||||
AND is_active = TRUE
|
||||
ORDER BY issued_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListActiveUserSessions(ctx context.Context, userID uuid.UUID) ([]UserSession, error) {
|
||||
rows, err := q.db.Query(ctx, listActiveUserSessions, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []UserSession
|
||||
for rows.Next() {
|
||||
var i UserSession
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.SessionType,
|
||||
&i.IssuedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastActive,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.AccessTokenID,
|
||||
&i.RefreshTokenID,
|
||||
&i.DeviceInfo,
|
||||
&i.IsActive,
|
||||
&i.RevokedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listAllSessions = `-- name: ListAllSessions :many
|
||||
SELECT id, user_id, session_type, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, device_info, is_active, revoked_at FROM user_sessions
|
||||
ORDER BY issued_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`
|
||||
|
||||
type ListAllSessionsParams struct {
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListAllSessions(ctx context.Context, arg ListAllSessionsParams) ([]UserSession, error) {
|
||||
rows, err := q.db.Query(ctx, listAllSessions, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []UserSession
|
||||
for rows.Next() {
|
||||
var i UserSession
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.SessionType,
|
||||
&i.IssuedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastActive,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.AccessTokenID,
|
||||
&i.RefreshTokenID,
|
||||
&i.DeviceInfo,
|
||||
&i.IsActive,
|
||||
&i.RevokedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const revokeUserSession = `-- name: RevokeUserSession :exec
|
||||
UPDATE user_sessions
|
||||
SET is_active = FALSE,
|
||||
revoked_at = NOW()
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE
|
||||
`
|
||||
|
||||
func (q *Queries) RevokeUserSession(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, revokeUserSession, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateSessionLastActive = `-- name: UpdateSessionLastActive :exec
|
||||
UPDATE user_sessions
|
||||
SET last_active = NOW()
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE
|
||||
`
|
||||
|
||||
func (q *Queries) UpdateSessionLastActive(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, updateSessionLastActive, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateSessionTokens = `-- name: UpdateSessionTokens :exec
|
||||
UPDATE user_sessions
|
||||
SET access_token_id = $2, refresh_token_id = $3, expires_at = $4
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE
|
||||
`
|
||||
|
||||
type UpdateSessionTokensParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
AccessTokenID *uuid.UUID `json:"access_token_id"`
|
||||
RefreshTokenID *uuid.UUID `json:"refresh_token_id"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateSessionTokens(ctx context.Context, arg UpdateSessionTokensParams) error {
|
||||
_, err := q.db.Exec(ctx, updateSessionTokens,
|
||||
arg.ID,
|
||||
arg.AccessTokenID,
|
||||
arg.RefreshTokenID,
|
||||
arg.ExpiresAt,
|
||||
)
|
||||
return err
|
||||
}
|
@ -197,3 +197,36 @@ func (q *Queries) UpdateProfilePicture(ctx context.Context, arg UpdateProfilePic
|
||||
_, err := q.db.Exec(ctx, updateProfilePicture, arg.ProfilePicture, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const userVerifyAvatar = `-- name: UserVerifyAvatar :exec
|
||||
UPDATE users
|
||||
SET avatar_verified = true
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) UserVerifyAvatar(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, userVerifyAvatar, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const userVerifyComplete = `-- name: UserVerifyComplete :exec
|
||||
UPDATE users
|
||||
SET verified = true
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) UserVerifyComplete(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, userVerifyComplete, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const userVerifyEmail = `-- name: UserVerifyEmail :exec
|
||||
UPDATE users
|
||||
SET email_verified = true
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) UserVerifyEmail(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, userVerifyEmail, id)
|
||||
return err
|
||||
}
|
||||
|
38
internal/types/apiservices.go
Normal file
38
internal/types/apiservices.go
Normal file
@ -0,0 +1,38 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ApiServiceDTO struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ClientID string `json:"client_id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
IconUrl *string `json:"icon_url"`
|
||||
RedirectUris []string `json:"redirect_uris"`
|
||||
Scopes []string `json:"scopes"`
|
||||
GrantTypes []string `json:"grant_types"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
func NewApiServiceDTO(service repository.ApiService) ApiServiceDTO {
|
||||
return ApiServiceDTO{
|
||||
ID: service.ID,
|
||||
ClientID: service.ClientID,
|
||||
Name: service.Name,
|
||||
Description: service.Description,
|
||||
IconUrl: service.IconUrl,
|
||||
RedirectUris: service.RedirectUris,
|
||||
Scopes: service.Scopes,
|
||||
GrantTypes: service.GrantTypes,
|
||||
CreatedAt: service.CreatedAt,
|
||||
UpdatedAt: service.UpdatedAt,
|
||||
IsActive: service.IsActive,
|
||||
}
|
||||
}
|
@ -8,13 +8,26 @@ type UserClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type ApiClaims struct {
|
||||
type IdTokenClaims struct {
|
||||
Email string `json:"email"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
Name string `json:"name"`
|
||||
Picture *string `json:"picture"`
|
||||
Nonce string `json:"nonce"`
|
||||
Nonce *string `json:"nonce"`
|
||||
Roles []string `json:"roles"`
|
||||
// TODO: add given_name, family_name, locale...
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type ApiClaims struct {
|
||||
// FIXME: correct permissions
|
||||
Permissions []string `json:"permissions"`
|
||||
jwt.RegisteredClaims
|
||||
// Subject = ClientID
|
||||
}
|
||||
|
||||
type ApiRefreshClaims struct {
|
||||
UserID string `json:"user_id"`
|
||||
jwt.RegisteredClaims
|
||||
// Subject = ClientID
|
||||
}
|
||||
|
12
internal/types/device.go
Normal file
12
internal/types/device.go
Normal file
@ -0,0 +1,12 @@
|
||||
package types
|
||||
|
||||
type DeviceInfo struct {
|
||||
DeviceType string `json:"device_type"`
|
||||
OS string `json:"os"`
|
||||
OSVersion string `json:"os_version"`
|
||||
Browser string `json:"browser"`
|
||||
BrowserVersion string `json:"browser_version"`
|
||||
DeviceName string `json:"device_name"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Location string `json:"location"`
|
||||
}
|
@ -3,4 +3,4 @@ package types
|
||||
type contextKey string
|
||||
|
||||
const UserIdKey contextKey = "userID"
|
||||
|
||||
const JTIKey contextKey = "jti"
|
||||
|
29
internal/types/session.go
Normal file
29
internal/types/session.go
Normal file
@ -0,0 +1,29 @@
|
||||
package types
|
||||
|
||||
import "gitea.local/admin/hspguard/internal/repository"
|
||||
|
||||
type ServiceSessionDTO struct {
|
||||
User UserDTO `json:"user"`
|
||||
ApiService ApiServiceDTO `json:"api_service"`
|
||||
repository.ServiceSession
|
||||
}
|
||||
|
||||
func NewServiceSessionDTO(row *repository.GetServiceSessionsRow) *ServiceSessionDTO {
|
||||
return &ServiceSessionDTO{
|
||||
User: NewUserDTO(&row.User),
|
||||
ApiService: NewApiServiceDTO(row.ApiService),
|
||||
ServiceSession: row.ServiceSession,
|
||||
}
|
||||
}
|
||||
|
||||
type UserSessionDTO struct {
|
||||
User UserDTO `json:"user"`
|
||||
repository.UserSession
|
||||
}
|
||||
|
||||
func NewUserSessionDTO(row *repository.GetUserSessionsRow) *UserSessionDTO {
|
||||
return &UserSessionDTO{
|
||||
User: NewUserDTO(&row.User),
|
||||
UserSession: row.UserSession,
|
||||
}
|
||||
}
|
21
internal/types/token.go
Normal file
21
internal/types/token.go
Normal file
@ -0,0 +1,21 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type SignedToken struct {
|
||||
Token string
|
||||
ExpiresAt time.Time
|
||||
ID uuid.UUID
|
||||
}
|
||||
|
||||
func NewSignedToken(token string, expiresAt time.Time, jti uuid.UUID) *SignedToken {
|
||||
return &SignedToken{
|
||||
Token: token,
|
||||
ExpiresAt: expiresAt,
|
||||
ID: jti,
|
||||
}
|
||||
}
|
@ -38,7 +38,7 @@ func NewUserHandler(repo *repository.Queries, minio *storage.FileStorage, cfg *c
|
||||
|
||||
func (h *UserHandler) RegisterRoutes(api chi.Router) {
|
||||
api.Group(func(protected chi.Router) {
|
||||
authMiddleware := imiddleware.NewAuthMiddleware(h.cfg)
|
||||
authMiddleware := imiddleware.NewAuthMiddleware(h.cfg, h.repo)
|
||||
protected.Use(authMiddleware.Runner)
|
||||
|
||||
protected.Put("/avatar", h.uploadAvatar)
|
||||
@ -166,6 +166,7 @@ func (h *UserHandler) uploadAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
ContentType: header.Header.Get("Content-Type"),
|
||||
})
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to put object:", err)
|
||||
web.Error(w, "failed to upload image", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@ -180,8 +181,16 @@ func (h *UserHandler) uploadAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if !user.AvatarVerified {
|
||||
if err := h.repo.UserVerifyAvatar(r.Context(), user.ID); err != nil {
|
||||
log.Println("ERR: Failed to update avatar_verified:", err)
|
||||
web.Error(w, "failed to verify avatar", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
AvatarID string `json:"url"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@ -190,7 +199,7 @@ func (h *UserHandler) uploadAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(Response{AvatarID: uploadInfo.Key}); err != nil {
|
||||
if err := encoder.Encode(Response{URL: fmt.Sprintf("%s/avatar/%s", h.cfg.Uri, uploadInfo.Key)}); err != nil {
|
||||
web.Error(w, "failed to write response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
@ -6,7 +6,6 @@ import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/types"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
@ -57,13 +56,12 @@ func SignJwtToken(claims jwt.Claims, key string) (string, error) {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func VerifyToken(token string, key string) (*jwt.Token, *types.UserClaims, error) {
|
||||
func VerifyToken(token string, key string, claims jwt.Claims) (*jwt.Token, error) {
|
||||
publicKey, err := ParseBase64PublicKey(key)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
claims := &types.UserClaims{}
|
||||
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
@ -72,12 +70,12 @@ func VerifyToken(token string, key string) (*jwt.Token, *types.UserClaims, error
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid token: %w", err)
|
||||
return nil, fmt.Errorf("invalid token: %w", err)
|
||||
}
|
||||
|
||||
if !parsed.Valid {
|
||||
return nil, nil, fmt.Errorf("token is not valid")
|
||||
return nil, fmt.Errorf("token is not valid")
|
||||
}
|
||||
|
||||
return parsed, claims, nil
|
||||
return parsed, nil
|
||||
}
|
||||
|
46
internal/util/location.go
Normal file
46
internal/util/location.go
Normal file
@ -0,0 +1,46 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type LocationResult struct {
|
||||
Country string `json:"country"`
|
||||
Region string `json:"regionName"`
|
||||
City string `json:"city"`
|
||||
}
|
||||
|
||||
func GetLocation(ip string) (LocationResult, error) {
|
||||
var loc LocationResult
|
||||
// Example using ipinfo.io free API
|
||||
resp, err := http.Get("http://ip-api.com/json/" + ip + "?fields=25")
|
||||
if err != nil {
|
||||
return loc, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
json.NewDecoder(resp.Body).Decode(&loc)
|
||||
return loc, nil
|
||||
}
|
||||
|
||||
func GetClientIP(r *http.Request) string {
|
||||
// This header will be set by ngrok to the original client IP
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
log.Printf("DEBUG: Getting IP from X-Forwarded-For: %s\n", xff)
|
||||
// X-Forwarded-For: client, proxy1, proxy2, ...
|
||||
ips := strings.Split(xff, ",")
|
||||
if len(ips) > 0 {
|
||||
return strings.TrimSpace(ips[0])
|
||||
}
|
||||
}
|
||||
// Fallback to RemoteAddr (not the real client IP, but just in case)
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
log.Printf("DEBUG: Falling to request remote addr: %s (%s)\n", host, r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
@ -11,3 +11,7 @@ func GetRequestUserId(ctx context.Context) (string, bool) {
|
||||
return userId, ok
|
||||
}
|
||||
|
||||
func GetRequestJTI(ctx context.Context) (string, bool) {
|
||||
jti, ok := ctx.Value(types.JTIKey).(string)
|
||||
return jti, ok
|
||||
}
|
||||
|
39
internal/util/session.go
Normal file
39
internal/util/session.go
Normal file
@ -0,0 +1,39 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/types"
|
||||
"github.com/avct/uasurfer"
|
||||
)
|
||||
|
||||
func BuildDeviceInfo(userAgent string, remoteAddr string) []byte {
|
||||
var deviceInfo types.DeviceInfo
|
||||
|
||||
parsed := uasurfer.Parse(userAgent)
|
||||
|
||||
deviceInfo.Browser = parsed.Browser.Name.StringTrimPrefix()
|
||||
deviceInfo.BrowserVersion = fmt.Sprintf("%d.%d.%d", parsed.Browser.Version.Major, parsed.Browser.Version.Minor, parsed.Browser.Version.Patch)
|
||||
deviceInfo.DeviceName = fmt.Sprintf("%s %s", parsed.OS.Platform.StringTrimPrefix(), parsed.OS.Name.StringTrimPrefix())
|
||||
deviceInfo.DeviceType = parsed.DeviceType.StringTrimPrefix()
|
||||
deviceInfo.OS = parsed.OS.Platform.StringTrimPrefix()
|
||||
deviceInfo.OSVersion = fmt.Sprintf("%d.%d.%d", parsed.OS.Version.Major, parsed.OS.Version.Minor, parsed.OS.Version.Patch)
|
||||
deviceInfo.UserAgent = userAgent
|
||||
|
||||
if location, err := GetLocation(remoteAddr); err != nil {
|
||||
log.Printf("WARN: Failed to get location from ip (%s): %v\n", remoteAddr, err)
|
||||
} else {
|
||||
log.Printf("DEBUG: Response from IP fetcher: %#v\n", location)
|
||||
deviceInfo.Location = fmt.Sprintf("%s, %s, %s", location.Country, location.Region, location.City)
|
||||
}
|
||||
|
||||
serialized, err := json.Marshal(deviceInfo)
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to serialize device info: %v\n", err)
|
||||
serialized = []byte{'{', '}'}
|
||||
}
|
||||
|
||||
return serialized
|
||||
}
|
12
migrations/00010_add_api_service_icon_url.sql
Normal file
12
migrations/00010_add_api_service_icon_url.sql
Normal file
@ -0,0 +1,12 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE api_services
|
||||
ADD COLUMN icon_url TEXT DEFAULT NULL;
|
||||
|
||||
-- +goose StatementEnd
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE api_services
|
||||
DROP COLUMN icon_url;
|
||||
|
||||
-- +goose StatementEnd
|
34
migrations/00011_add_user_sessions_table.sql
Normal file
34
migrations/00011_add_user_sessions_table.sql
Normal file
@ -0,0 +1,34 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE user_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
|
||||
user_id UUID REFERENCES users (id) NOT NULL,
|
||||
session_type VARCHAR(32) NOT NULL DEFAULT 'user', -- e.g. 'user', 'admin'
|
||||
issued_at TIMESTAMP
|
||||
WITH
|
||||
TIME ZONE NOT NULL DEFAULT NOW (),
|
||||
expires_at TIMESTAMP
|
||||
WITH
|
||||
TIME ZONE,
|
||||
last_active TIMESTAMP
|
||||
WITH
|
||||
TIME ZONE,
|
||||
ip_address VARCHAR(45), -- supports IPv4/IPv6
|
||||
user_agent TEXT,
|
||||
access_token_id UUID,
|
||||
refresh_token_id UUID,
|
||||
device_info JSONB, -- optional: structured info (browser, OS, etc.)
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
revoked_at TIMESTAMP
|
||||
WITH
|
||||
TIME ZONE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions (user_id);
|
||||
|
||||
-- +goose StatementEnd
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS user_sessions;
|
||||
|
||||
-- +goose StatementEnd
|
38
migrations/00012_add_service_sessions.sql
Normal file
38
migrations/00012_add_service_sessions.sql
Normal file
@ -0,0 +1,38 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE service_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
|
||||
service_id UUID REFERENCES api_services (id) NOT NULL,
|
||||
client_id TEXT NOT NULL,
|
||||
user_id UUID REFERENCES users (id), -- user on behalf of whom the service is acting, nullable for direct use with client creds
|
||||
issued_at TIMESTAMP
|
||||
WITH
|
||||
TIME ZONE NOT NULL DEFAULT NOW (),
|
||||
expires_at TIMESTAMP
|
||||
WITH
|
||||
TIME ZONE,
|
||||
last_active TIMESTAMP
|
||||
WITH
|
||||
TIME ZONE,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
access_token_id UUID,
|
||||
refresh_token_id UUID,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
revoked_at TIMESTAMP
|
||||
WITH
|
||||
TIME ZONE,
|
||||
scope TEXT, -- what scopes/permissions this session was issued for
|
||||
claims JSONB -- snapshot of claims at session start, optional
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_service_sessions_client_id ON service_sessions (client_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_service_sessions_user_id ON service_sessions (user_id);
|
||||
|
||||
-- +goose StatementEnd
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS service_sessions;
|
||||
|
||||
-- +goose StatementEnd
|
69
queries/service_sessions.sql
Normal file
69
queries/service_sessions.sql
Normal file
@ -0,0 +1,69 @@
|
||||
-- name: CreateServiceSession :one
|
||||
INSERT INTO service_sessions (
|
||||
service_id, client_id, user_id, issued_at, expires_at, last_active,
|
||||
ip_address, user_agent, access_token_id, refresh_token_id,
|
||||
is_active, scope, claims
|
||||
) VALUES (
|
||||
$1, $2, $3, NOW(), $4, $5,
|
||||
$6, $7, $8, $9,
|
||||
TRUE, $10, $11
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListActiveServiceSessionsByClient :many
|
||||
SELECT * FROM service_sessions
|
||||
WHERE client_id = $1
|
||||
AND is_active = TRUE
|
||||
ORDER BY issued_at DESC
|
||||
LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: ListActiveServiceSessionsByUser :many
|
||||
SELECT * FROM service_sessions
|
||||
WHERE user_id = $1
|
||||
AND is_active = TRUE
|
||||
ORDER BY issued_at DESC
|
||||
LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: GetServiceSessionByAccessJTI :one
|
||||
SELECT * FROM service_sessions
|
||||
WHERE access_token_id = $1
|
||||
AND is_active = TRUE;
|
||||
|
||||
-- name: GetServiceSessionByRefreshJTI :one
|
||||
SELECT * FROM service_sessions
|
||||
WHERE refresh_token_id = $1;
|
||||
|
||||
-- name: RevokeServiceSession :exec
|
||||
UPDATE service_sessions
|
||||
SET is_active = FALSE,
|
||||
revoked_at = NOW()
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE;
|
||||
|
||||
-- name: UpdateServiceSessionLastActive :exec
|
||||
UPDATE service_sessions
|
||||
SET last_active = NOW()
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE;
|
||||
|
||||
-- name: UpdateServiceSessionTokens :exec
|
||||
UPDATE service_sessions
|
||||
SET access_token_id = $2, refresh_token_id = $3, expires_at = $4
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE;
|
||||
|
||||
-- name: ListAllServiceSessions :many
|
||||
SELECT * FROM service_sessions
|
||||
ORDER BY issued_at DESC
|
||||
LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: GetServiceSessions :many
|
||||
SELECT sqlc.embed(session), sqlc.embed(service), sqlc.embed(u)
|
||||
FROM service_sessions AS session
|
||||
JOIN api_services AS service ON service.id = session.service_id
|
||||
JOIN users AS u ON u.id = session.user_id
|
||||
ORDER BY session.issued_at DESC
|
||||
LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: GetServiceSessionsCount :one
|
||||
SELECT COUNT(*) FROM service_sessions;
|
60
queries/user_sessions.sql
Normal file
60
queries/user_sessions.sql
Normal file
@ -0,0 +1,60 @@
|
||||
-- name: CreateUserSession :one
|
||||
INSERT INTO user_sessions (
|
||||
user_id, session_type, issued_at, expires_at, last_active,
|
||||
ip_address, user_agent, access_token_id, refresh_token_id,
|
||||
device_info, is_active
|
||||
) VALUES (
|
||||
$1, $2, NOW(), $3, $4,
|
||||
$5, $6, $7, $8,
|
||||
$9, TRUE
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListActiveUserSessions :many
|
||||
SELECT * FROM user_sessions
|
||||
WHERE user_id = $1
|
||||
AND is_active = TRUE
|
||||
ORDER BY issued_at DESC;
|
||||
|
||||
-- name: GetUserSessionByAccessJTI :one
|
||||
SELECT * FROM user_sessions
|
||||
WHERE access_token_id = $1
|
||||
AND is_active = TRUE;
|
||||
|
||||
-- name: GetUserSessionByRefreshJTI :one
|
||||
SELECT * FROM user_sessions
|
||||
WHERE refresh_token_id = $1;
|
||||
|
||||
-- name: RevokeUserSession :exec
|
||||
UPDATE user_sessions
|
||||
SET is_active = FALSE,
|
||||
revoked_at = NOW()
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE;
|
||||
|
||||
-- name: UpdateSessionLastActive :exec
|
||||
UPDATE user_sessions
|
||||
SET last_active = NOW()
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE;
|
||||
|
||||
-- name: UpdateSessionTokens :exec
|
||||
UPDATE user_sessions
|
||||
SET access_token_id = $2, refresh_token_id = $3, expires_at = $4
|
||||
WHERE id = $1
|
||||
AND is_active = TRUE;
|
||||
|
||||
-- name: ListAllSessions :many
|
||||
SELECT * FROM user_sessions
|
||||
ORDER BY issued_at DESC
|
||||
LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: GetUserSessions :many
|
||||
SELECT sqlc.embed(session), sqlc.embed(u)
|
||||
FROM user_sessions AS session
|
||||
JOIN users AS u ON u.id = session.user_id
|
||||
ORDER BY session.issued_at DESC
|
||||
LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: GetUserSessionsCount :one
|
||||
SELECT COUNT(*) FROM user_sessions;
|
@ -23,6 +23,21 @@ UPDATE users
|
||||
SET profile_picture = $1
|
||||
WHERE id = $2;
|
||||
|
||||
-- name: UserVerifyEmail :exec
|
||||
UPDATE users
|
||||
SET email_verified = true
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: UserVerifyAvatar :exec
|
||||
UPDATE users
|
||||
SET avatar_verified = true
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: UserVerifyComplete :exec
|
||||
UPDATE users
|
||||
SET verified = true
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: UpdateLastLogin :exec
|
||||
UPDATE users
|
||||
SET last_login = NOW()
|
||||
|
4
redis.conf
Normal file
4
redis.conf
Normal file
@ -0,0 +1,4 @@
|
||||
|
||||
# Enable ACL
|
||||
user default off
|
||||
user guard on >guard allcommands allkeys
|
25
sqlc.yaml
25
sqlc.yaml
@ -41,20 +41,39 @@ sql:
|
||||
# ───── text ──────────────────────────────────────────
|
||||
- db_type: "pg_catalog.text"
|
||||
go_type: { type: "string" }
|
||||
- db_type: "text" # or just "bool"
|
||||
|
||||
- db_type: "text"
|
||||
go_type: { type: "string" }
|
||||
|
||||
- db_type: "pg_catalog.text"
|
||||
nullable: true
|
||||
go_type:
|
||||
type: "string"
|
||||
pointer: true # ⇒ *bool for NULLable columns
|
||||
pointer: true
|
||||
|
||||
- db_type: "text"
|
||||
nullable: true
|
||||
go_type:
|
||||
type: "string"
|
||||
pointer: true # ⇒ *bool for NULLable columns
|
||||
pointer: true
|
||||
|
||||
- db_type: "pg_catalog.varchar"
|
||||
go_type: { type: "string" }
|
||||
|
||||
- db_type: "varchar"
|
||||
go_type: { type: "string" }
|
||||
|
||||
- db_type: "pg_catalog.varchar"
|
||||
nullable: true
|
||||
go_type:
|
||||
type: "string"
|
||||
pointer: true
|
||||
|
||||
- db_type: "varchar"
|
||||
nullable: true
|
||||
go_type:
|
||||
type: "string"
|
||||
pointer: true
|
||||
|
||||
# ───── timestamp (WITHOUT TZ) ────────────────────────
|
||||
- db_type: "pg_catalog.timestamp" # or "timestamp"
|
||||
|
10
web/package-lock.json
generated
10
web/package-lock.json
generated
@ -12,6 +12,7 @@
|
||||
"axios": "^1.9.0",
|
||||
"idb": "^8.0.3",
|
||||
"lucide-react": "^0.511.0",
|
||||
"moment": "^2.30.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
@ -3809,6 +3810,15 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/moment": {
|
||||
"version": "2.30.1",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
|
@ -15,6 +15,7 @@
|
||||
"axios": "^1.9.0",
|
||||
"idb": "^8.0.3",
|
||||
"lucide-react": "^0.511.0",
|
||||
"moment": "^2.30.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { type FC } from "react";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router";
|
||||
import { createBrowserRouter, Navigate, RouterProvider } from "react-router";
|
||||
|
||||
import IndexPage from "./pages/Index";
|
||||
import LoginPage from "./pages/Login";
|
||||
@ -25,6 +25,8 @@ import VerifyEmailPage from "./pages/Verify/Email";
|
||||
import VerifyEmailOtpPage from "./pages/Verify/Email/OTP";
|
||||
import VerifyAvatarPage from "./pages/Verify/Avatar";
|
||||
import VerifyReviewPage from "./pages/Verify/Review";
|
||||
import AdminUserSessionsPage from "./pages/Admin/UserSessions";
|
||||
import AdminServiceSessionsPage from "./pages/Admin/ServiceSessions";
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
@ -47,6 +49,10 @@ const router = createBrowserRouter([
|
||||
path: "admin",
|
||||
element: <AdminLayout />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <Navigate to="/admin/api-services" />,
|
||||
},
|
||||
{
|
||||
path: "api-services",
|
||||
children: [
|
||||
@ -77,6 +83,16 @@ const router = createBrowserRouter([
|
||||
// },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "user-sessions",
|
||||
children: [{ index: true, element: <AdminUserSessionsPage /> }],
|
||||
},
|
||||
{
|
||||
path: "service-sessions",
|
||||
children: [
|
||||
{ index: true, element: <AdminServiceSessionsPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
@ -56,6 +56,19 @@ export const getApiService = async (id: string): Promise<ApiService> => {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getApiServiceCID = async (
|
||||
clientId: string,
|
||||
): Promise<ApiService> => {
|
||||
const response = await axios.get<ApiService>(
|
||||
`/api/v1/api-services/client/${clientId}`,
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const patchToggleApiService = async (id: string): Promise<void> => {
|
||||
const response = await axios.patch(`/api/v1/admin/api-services/toggle/${id}`);
|
||||
|
||||
|
78
web/src/api/admin/sessions.ts
Normal file
78
web/src/api/admin/sessions.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import type { ServiceSession, UserSession } from "@/types";
|
||||
import { axios, handleApiError } from "..";
|
||||
|
||||
export interface FetchUserSessionsRequest {
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface FetchUserSessionsResponse {
|
||||
items: UserSession[];
|
||||
page: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export const adminGetUserSessionsApi = async (
|
||||
req: FetchUserSessionsRequest,
|
||||
): Promise<FetchUserSessionsResponse> => {
|
||||
const response = await axios.get<FetchUserSessionsResponse>(
|
||||
"/api/v1/admin/user-sessions",
|
||||
{
|
||||
params: req,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const adminRevokeUserSessionApi = async (
|
||||
sessionId: string,
|
||||
): Promise<void> => {
|
||||
const response = await axios.patch<FetchServiceSessionsResponse>(
|
||||
`/api/v1/admin/user-sessions/revoke/${sessionId}`,
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
};
|
||||
|
||||
export interface FetchServiceSessionsRequest {
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface FetchServiceSessionsResponse {
|
||||
items: ServiceSession[];
|
||||
page: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export const adminGetServiceSessionsApi = async (
|
||||
req: FetchServiceSessionsRequest,
|
||||
): Promise<FetchServiceSessionsResponse> => {
|
||||
const response = await axios.get<FetchServiceSessionsResponse>(
|
||||
"/api/v1/admin/service-sessions",
|
||||
{
|
||||
params: req,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const adminRevokeServiceSessionApi = async (
|
||||
sessionId: string,
|
||||
): Promise<void> => {
|
||||
const response = await axios.patch<FetchServiceSessionsResponse>(
|
||||
`/api/v1/admin/service-sessions/revoke/${sessionId}`,
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
};
|
17
web/src/api/avatar.ts
Normal file
17
web/src/api/avatar.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { axios, handleApiError } from ".";
|
||||
|
||||
export const uploadAvatarApi = async (imageFile: File): Promise<string> => {
|
||||
const formData = new FormData();
|
||||
formData.append("image", imageFile);
|
||||
|
||||
const response = await axios.put("/api/v1/avatar", formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
@ -4,10 +4,14 @@ export interface CodeResponse {
|
||||
code: string;
|
||||
}
|
||||
|
||||
export const codeApi = async (accessToken: string, nonce: string) => {
|
||||
export const codeApi = async (
|
||||
accessToken: string,
|
||||
nonce: string,
|
||||
clientId: string,
|
||||
) => {
|
||||
const response = await axios.post(
|
||||
"/api/v1/oauth/code",
|
||||
{ nonce },
|
||||
{ nonce, client_id: clientId },
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
@ -27,11 +27,13 @@ const processRefreshQueue = async (token: string | null) => {
|
||||
|
||||
const logout = async (accountId: string) => {
|
||||
const db = useDbStore.getState().db;
|
||||
const requireSignIn = useAuth.getState().requireSignIn;
|
||||
const { requireSignIn, loadAccounts } = useAuth.getState();
|
||||
|
||||
if (db) {
|
||||
await deleteAccount(db, accountId);
|
||||
}
|
||||
await loadAccounts();
|
||||
|
||||
requireSignIn?.();
|
||||
};
|
||||
|
||||
|
21
web/src/api/signout.ts
Normal file
21
web/src/api/signout.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import axios from "axios";
|
||||
import { handleApiError } from ".";
|
||||
|
||||
export const signoutApi = async (token: string) => {
|
||||
const response = await axios.post(
|
||||
"/api/v1/auth/signout",
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
const data = response.data;
|
||||
|
||||
return data;
|
||||
};
|
34
web/src/api/verify.ts
Normal file
34
web/src/api/verify.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { axios, handleApiError } from ".";
|
||||
|
||||
export const requestEmailOtpApi = async (): Promise<void> => {
|
||||
const response = await axios.post("/api/v1/auth/email");
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export interface ConfirmEmailRequest {
|
||||
otp: string;
|
||||
}
|
||||
|
||||
export const confirmEmailApi = async (
|
||||
req: ConfirmEmailRequest,
|
||||
): Promise<void> => {
|
||||
const response = await axios.post("/api/v1/auth/email/otp", req);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const finishVerificationApi = async (): Promise<void> => {
|
||||
const response = await axios.post("/api/v1/auth/verify");
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
@ -7,7 +7,8 @@ const Sidebar: FC = () => {
|
||||
|
||||
return (
|
||||
<div className="hidden sm:flex flex-col gap-2 items-stretch border-r border-gray-300 dark:border-gray-700 min-w-80 w-80 p-5 pt-18 min-h-screen select-none">
|
||||
{barItems.map((item) => (
|
||||
{barItems.map((item, index) =>
|
||||
item.type !== "delimiter" ? (
|
||||
<Link to={item.pathname} key={item.tab}>
|
||||
<div
|
||||
className={`dark:text-gray-200 transition-colors text-sm cursor-pointer p-4 rounded-lg flex flex-row items-center gap-3${
|
||||
@ -17,7 +18,21 @@ const Sidebar: FC = () => {
|
||||
{item.icon} {item.title}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
) : (
|
||||
<div
|
||||
key={item.key}
|
||||
className={`flex flex-row items-center gap-4 my-2 ${index === 0 ? "mt-0" : "mt-4"}`}
|
||||
>
|
||||
<div className="w-full h-[2px] rounded-lg bg-gray-800"></div>
|
||||
{typeof item.title === "string" && (
|
||||
<p className="text-gray-800 dark:text-gray-400 text-sm">
|
||||
{item.title}
|
||||
</p>
|
||||
)}
|
||||
<div className="w-full h-[2px] rounded-lg bg-gray-800"></div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -6,8 +6,10 @@ const TopBar: FC = () => {
|
||||
const [barItems, isActive] = useBarItems();
|
||||
|
||||
return (
|
||||
<div className="sm:hidden flex w-full overflow-x-auto sm:overflow-x-visible max-w-full min-w-full sm:justify-center sm:space-x-4 no-scrollbar shadow-md shadow-gray-300 dark:shadow-gray-700 dark:bg-black/70 bg-white/70 pt-14">
|
||||
{barItems.map((item) => (
|
||||
<div className="sm:hidden flex w-full overflow-x-auto sm:overflow-x-visible max-w-full min-w-full sm:justify-center sm:space-x-4 no-scrollbar shadow-md shadow-gray-300 dark:shadow-gray-700 dark:bg-black/70 bg-white/70">
|
||||
{barItems
|
||||
.filter((item) => item.type !== "delimiter")
|
||||
.map((item) => (
|
||||
<Link to={item.pathname} key={item.tab}>
|
||||
<div
|
||||
className={`flex-shrink-0 transition-all border-b-4 px-4 py-2 min-w-[120px] sm:min-w-0 sm:flex-1 flex items-center justify-center cursor-pointer select-none whitespace-nowrap text-sm font-medium ${
|
||||
|
64
web/src/components/ui/pagination.tsx
Normal file
64
web/src/components/ui/pagination.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||
import React, { useCallback } from "react";
|
||||
import { Button } from "./button";
|
||||
|
||||
type PaginationProps = {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
};
|
||||
|
||||
const Pagination: React.FC<PaginationProps> = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
}) => {
|
||||
const getPageNumbers = useCallback(() => {
|
||||
const delta = 2;
|
||||
const pages = [];
|
||||
|
||||
for (
|
||||
let i = Math.max(1, currentPage - delta);
|
||||
i <= Math.min(totalPages, currentPage + delta);
|
||||
i++
|
||||
) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
return pages;
|
||||
}, [currentPage, totalPages]);
|
||||
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
return (
|
||||
<nav className="flex justify-center items-center gap-2 mt-4">
|
||||
<Button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
variant="outlined"
|
||||
>
|
||||
<ArrowLeft size={17} />
|
||||
</Button>
|
||||
|
||||
{getPageNumbers().map((page) => (
|
||||
<Button
|
||||
key={page}
|
||||
onClick={() => onPageChange(page)}
|
||||
variant={page === currentPage ? "contained" : "outlined"}
|
||||
>
|
||||
<p className="text-sm">{page}</p>
|
||||
</Button>
|
||||
))}
|
||||
|
||||
<Button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
variant="outlined"
|
||||
>
|
||||
<ArrowRight size={17} />
|
||||
</Button>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Pagination;
|
@ -27,7 +27,6 @@ export const Stepper: React.FC<StepperProps> = ({ steps, currentStep }) => {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center w-full max-w-2xl mx-auto mb-5 sm:mb-8 gap-5 relative">
|
||||
{steps.map((step, idx) => (
|
||||
<>
|
||||
<div
|
||||
key={idx}
|
||||
className={`sm:flex p-4 pb-0 sm:p-0 flex-1 items-center ${idx < stepIndex ? "opacity-70" : ""} ${idx === stepIndex ? "flex" : "hidden"}`}
|
||||
@ -80,7 +79,6 @@ export const Stepper: React.FC<StepperProps> = ({ steps, currentStep }) => {
|
||||
<div className="flex-1 h-1 mx-2 min-w sm:mx-4 rounded bg-gray-300 dark:bg-gray-600" />
|
||||
)} */}
|
||||
</div>
|
||||
</>
|
||||
))}
|
||||
<div className="sm:hidden relative h-1 w-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
|
||||
<div
|
||||
|
@ -1,21 +1,31 @@
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { Blocks, Home, Settings2, User, Users } from "lucide-react";
|
||||
import { Blocks, EarthLock, Home, User, UserLock, Users } from "lucide-react";
|
||||
import { useCallback, type ReactNode } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
|
||||
export interface BarDelimiter {
|
||||
type: "delimiter";
|
||||
key: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface BarItem {
|
||||
type?: "nav";
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
tab: string;
|
||||
pathname: string;
|
||||
}
|
||||
|
||||
export const useBarItems = (): [BarItem[], (item: BarItem) => boolean] => {
|
||||
export type Item = BarItem | BarDelimiter;
|
||||
|
||||
export const useBarItems = (): [Item[], (item: Item) => boolean] => {
|
||||
const profile = useAuth((state) => state.profile);
|
||||
const location = useLocation();
|
||||
|
||||
const isActive = useCallback(
|
||||
(item: BarItem) => {
|
||||
(item: Item) => {
|
||||
if (item.type === "delimiter") return false;
|
||||
if (item.pathname === "/") return location.pathname === item.pathname;
|
||||
return location.pathname.startsWith(item.pathname);
|
||||
},
|
||||
@ -28,6 +38,11 @@ export const useBarItems = (): [BarItem[], (item: BarItem) => boolean] => {
|
||||
|
||||
return [
|
||||
[
|
||||
{
|
||||
type: "delimiter" as const,
|
||||
title: "Basic",
|
||||
key: "basic-del",
|
||||
},
|
||||
{
|
||||
icon: <Home />,
|
||||
title: "Home",
|
||||
@ -40,14 +55,20 @@ export const useBarItems = (): [BarItem[], (item: BarItem) => boolean] => {
|
||||
tab: "personal-info",
|
||||
pathname: "/personal-info",
|
||||
},
|
||||
{
|
||||
icon: <Settings2 />,
|
||||
title: "Data & Personalization",
|
||||
tab: "data-personalization",
|
||||
pathname: "/data-personalize",
|
||||
},
|
||||
// TODO:
|
||||
// {
|
||||
// icon: <Settings2 />,
|
||||
// title: "Data & Personalization",
|
||||
// tab: "data-personalization",
|
||||
// pathname: "/data-personalize",
|
||||
// },
|
||||
...(profile.is_admin
|
||||
? [
|
||||
{
|
||||
type: "delimiter" as const,
|
||||
title: "Admin",
|
||||
key: "admin-del",
|
||||
},
|
||||
{
|
||||
icon: <Blocks />,
|
||||
title: "API Services",
|
||||
@ -60,6 +81,18 @@ export const useBarItems = (): [BarItem[], (item: BarItem) => boolean] => {
|
||||
tab: "admin.users",
|
||||
pathname: "/admin/users",
|
||||
},
|
||||
{
|
||||
icon: <UserLock />,
|
||||
title: "User Sessions",
|
||||
tab: "admin.user-sessions",
|
||||
pathname: "/admin/user-sessions",
|
||||
},
|
||||
{
|
||||
icon: <EarthLock />,
|
||||
title: "Service Sessions",
|
||||
tab: "admin.service-sessions",
|
||||
pathname: "/admin/service-sessions",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
|
@ -63,6 +63,18 @@ const AuthLayout = () => {
|
||||
connecting,
|
||||
]);
|
||||
|
||||
const verificationRequired = useMemo(() => {
|
||||
return (
|
||||
authProfile?.email_verified === false ||
|
||||
authProfile?.avatar_verified === false ||
|
||||
authProfile?.verified === false
|
||||
);
|
||||
}, [
|
||||
authProfile?.avatar_verified,
|
||||
authProfile?.email_verified,
|
||||
authProfile?.verified,
|
||||
]);
|
||||
|
||||
// OAuth
|
||||
useEffect(() => {
|
||||
console.log(
|
||||
@ -140,10 +152,10 @@ const AuthLayout = () => {
|
||||
|
||||
if (
|
||||
!signInRequired &&
|
||||
authProfile?.email_verified === false &&
|
||||
verificationRequired &&
|
||||
!location.pathname.startsWith("/verify")
|
||||
) {
|
||||
return <Navigate to="/verify" />;
|
||||
return <Navigate to="/verify" state={{ from: location.pathname }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -7,9 +7,9 @@ import { Outlet } from "react-router";
|
||||
const DashboardLayout: FC = () => {
|
||||
return (
|
||||
<div className="relative z-10 flex items-center justify-center min-h-screen">
|
||||
<Card className="min-h-screen w-full min-w-full max-h-screen shadow-lg bg-white/85 dark:bg-black/85 backdrop-blur-md sm:rounded-none overflow-y-auto sm:overflow-hidden">
|
||||
<div className="flex flex-col w-full h-full flex-1 items-center sm:pt-0 relative">
|
||||
<div className="flex flex-row items-center absolute left-4 top-4">
|
||||
<Card className="min-h-screen w-full min-w-full h-screen max-h-screen shadow-lg bg-white/85 dark:bg-black/85 backdrop-blur-md sm:rounded-none overflow-y-auto sm:overflow-hidden">
|
||||
<div className="flex flex-col w-full h-full flex-1 items-center sm:pt-0">
|
||||
<div className="flex w-full sm:w-auto p-4 sm:p-0 flex-row items-center sm:absolute sm:left-4 sm:top-4">
|
||||
<img src="/icon.png" alt="icon" className="w-6 h-6" />
|
||||
|
||||
<div className="ml-2">
|
||||
@ -20,12 +20,12 @@ const DashboardLayout: FC = () => {
|
||||
</div>
|
||||
|
||||
<CardContent
|
||||
className="w-full h-full max-h-full space-y-4 flex-1 bg-black/5 dark:bg-white/5"
|
||||
className="w-full space-y-4 flex-1 bg-black/5 dark:bg-white/5"
|
||||
spacing={false}
|
||||
>
|
||||
<div className="flex flex-row">
|
||||
<Sidebar />
|
||||
<div className="max-w-full flex-1 sm:max-h-screen overflow-y-auto">
|
||||
<div className="max-w-full flex-1 sm:overflow-y-auto sm:max-h-screen">
|
||||
<div className="flex flex-col w-full items-center gap-2">
|
||||
<TopBar />
|
||||
</div>
|
||||
|
@ -3,7 +3,7 @@ import { useAuth } from "@/store/auth";
|
||||
import { useVerify } from "@/store/verify";
|
||||
import { Eye, MailCheck, ScanFace } from "lucide-react";
|
||||
import { useEffect, type FC } from "react";
|
||||
import { Outlet, useLocation } from "react-router";
|
||||
import { Navigate, Outlet, useLocation, useNavigate } from "react-router";
|
||||
|
||||
const steps = [
|
||||
{
|
||||
@ -33,15 +33,50 @@ const VerificationLayout: FC = () => {
|
||||
const step = useVerify((s) => s.step);
|
||||
const loadStep = useVerify((s) => s.loadStep);
|
||||
|
||||
const redirect = useVerify((s) => s.redirect);
|
||||
const setRedirect = useVerify((s) => s.setRedirect);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (profile) loadStep(profile);
|
||||
}, [loadStep, profile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (location.state?.from) {
|
||||
setRedirect(location.state.from);
|
||||
}
|
||||
}, [location.state?.from, setRedirect]);
|
||||
|
||||
useEffect(() => {
|
||||
if (step === false) {
|
||||
navigate(redirect ?? "/", { state: { reset: true } });
|
||||
}
|
||||
}, [navigate, redirect, step]);
|
||||
|
||||
if (
|
||||
step === "email" &&
|
||||
!location.pathname.startsWith("/verify/email") &&
|
||||
location.pathname.replace(/\/$/i, "") !== "/verify"
|
||||
) {
|
||||
return <Navigate to="/verify/email" />;
|
||||
}
|
||||
|
||||
if (step === "avatar" && !location.pathname.startsWith("/verify/avatar")) {
|
||||
return <Navigate to="/verify/avatar" />;
|
||||
}
|
||||
|
||||
if (step === "review" && !location.pathname.startsWith("/verify/review")) {
|
||||
return <Navigate to="/verify/review" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-screen max-h-screen overflow-y-auto flex flex-col items-center sm:justify-center bg-white/50 dark:bg-black/50">
|
||||
<div className="w-full h-full sm:w-auto sm:h-auto">
|
||||
{location.pathname.replace(/\/$/i, "") !== "/verify" &&
|
||||
step != null && <Stepper steps={steps} currentStep={step} />}
|
||||
typeof step === "string" && (
|
||||
<Stepper steps={steps} currentStep={step} />
|
||||
)}
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -33,9 +33,9 @@ const ApiServiceCreatePage: FC = () => {
|
||||
const credentials = useApiServices((state) => state.createdCredentials);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(data: FormData) => {
|
||||
async (data: FormData) => {
|
||||
console.log("Form submitted:", data);
|
||||
createApiService({
|
||||
await createApiService({
|
||||
name: data.name,
|
||||
description: data.description ?? "",
|
||||
redirect_uris: data.redirectUris.trim().split("\n"),
|
||||
@ -45,6 +45,9 @@ const ApiServiceCreatePage: FC = () => {
|
||||
: ["authorization_code"],
|
||||
is_active: data.enabled,
|
||||
});
|
||||
// if (success) {
|
||||
// navigate("/admin/api-services");
|
||||
// }
|
||||
},
|
||||
[createApiService],
|
||||
);
|
||||
|
@ -15,7 +15,7 @@ const ApiServicesPage: FC = () => {
|
||||
}, [fetchApiServices]);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col items-stretch w-full h-full">
|
||||
<div className="relative flex flex-col items-stretch w-full">
|
||||
<div className="p-4">
|
||||
<Breadcrumbs
|
||||
className="pb-2"
|
||||
|
214
web/src/pages/Admin/ServiceSessions/index.tsx
Normal file
214
web/src/pages/Admin/ServiceSessions/index.tsx
Normal file
@ -0,0 +1,214 @@
|
||||
import Breadcrumbs from "@/components/ui/breadcrumbs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Avatar from "@/feature/Avatar";
|
||||
import { Ban } from "lucide-react";
|
||||
import { useCallback, useEffect, type FC } from "react";
|
||||
import { Link } from "react-router";
|
||||
import moment from "moment";
|
||||
import Pagination from "@/components/ui/pagination";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { useServiceSessions } from "@/store/admin/serviceSessions";
|
||||
|
||||
const AdminServiceSessionsPage: FC = () => {
|
||||
const loading = useServiceSessions((s) => s.loading);
|
||||
const sessions = useServiceSessions((s) => s.items);
|
||||
|
||||
const page = useServiceSessions((s) => s.page);
|
||||
const totalPages = useServiceSessions((s) => s.totalPages);
|
||||
|
||||
const fetchSessions = useServiceSessions((s) => s.fetch);
|
||||
const revokeSession = useServiceSessions((s) => s.revoke);
|
||||
|
||||
const revokingId = useServiceSessions((s) => s.revokingId);
|
||||
|
||||
const profile = useAuth((s) => s.profile);
|
||||
|
||||
const handleRevokeSession = useCallback(
|
||||
(id: string) => {
|
||||
revokeSession(id);
|
||||
},
|
||||
[revokeSession],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSessions(1);
|
||||
}, [fetchSessions]);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col items-stretch w-full">
|
||||
<div className="p-4">
|
||||
<Breadcrumbs
|
||||
className="pb-2"
|
||||
items={[
|
||||
{
|
||||
href: "/admin",
|
||||
label: "Admin",
|
||||
},
|
||||
{
|
||||
label: "Service Sessions",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4 flex flex-row items-center justify-between">
|
||||
<p className="text-gray-800 dark:text-gray-300">Search...</p>
|
||||
{/* TODO: Filters */}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-x-auto">
|
||||
<table className="relative min-w-full border-l-0 border border-gray-300 dark:border-gray-700 border-collapse divide-y divide-gray-200 dark:divide-gray-800">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/60 dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<div className="text-gray-800 dark:text-gray-200 font-medium">
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<thead className="bg-black/5 dark:bg-white/5 text-nowrap">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Service
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
User + IP
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Issued At
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Expires At
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Last Active
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Revoked At
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{!loading && sessions.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={5}
|
||||
className="px-6 py-12 text-center text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
No sessions found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sessions.map((session) => (
|
||||
<tr
|
||||
key={session.id}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
<td className="px-5 py-3 text-sm text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-700">
|
||||
{/* <SessionSource deviceInfo={session.} /> */}
|
||||
{typeof session.api_service?.icon_url === "string" && (
|
||||
<Avatar
|
||||
avatarId={session.api_service.icon_url}
|
||||
className="w-7 h-7 min-w-7"
|
||||
/>
|
||||
)}
|
||||
<Link to={`/admin/api-services/view/${session.service_id}`}>
|
||||
<p className="cursor-pointer text-blue-500 text-nowrap">
|
||||
{session.api_service?.name ?? session.client_id}
|
||||
</p>
|
||||
</Link>
|
||||
</td>
|
||||
|
||||
<td className="px-5 py-3 text-sm text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-700">
|
||||
<div className="flex flex-col items-stretch gap-2 justify-start">
|
||||
<div className="flex flex-row items-center gap-2 justify-start">
|
||||
{typeof session.user?.profile_picture === "string" && (
|
||||
<Avatar
|
||||
avatarId={session.user.profile_picture}
|
||||
className="w-7 h-7 min-w-7"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-stretch justify-center">
|
||||
<Link to={`/admin/users/view/${session.user_id}`}>
|
||||
<p className="cursor-pointer text-blue-500 text-nowrap">
|
||||
{session.user?.full_name ?? ""}{" "}
|
||||
{session.user_id === profile?.id ? "(You)" : ""}
|
||||
</p>
|
||||
</Link>
|
||||
<p className="opacity-75">
|
||||
{session.ip_address ?? "No IP available"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm border border-gray-300 dark:border-gray-700">
|
||||
<span
|
||||
className={`inline-block px-2 py-1 text-xs rounded-full font-semibold ${
|
||||
!session.is_active ||
|
||||
(session.expires_at &&
|
||||
moment(session.expires_at).isSameOrBefore(
|
||||
moment(new Date()),
|
||||
))
|
||||
? "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300"
|
||||
: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300"
|
||||
}`}
|
||||
>
|
||||
{session.is_active ? "Active" : "Inactive"}
|
||||
{moment(session.expires_at).isSameOrBefore(
|
||||
moment(new Date()),
|
||||
) && " (Expired)"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-700">
|
||||
{moment(session.issued_at).format("LLLL")}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-700">
|
||||
{session.expires_at
|
||||
? moment(session.expires_at).format("LLLL")
|
||||
: "never"}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-700">
|
||||
{session.last_active
|
||||
? moment(session.last_active).format("LLLL")
|
||||
: "never"}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-700">
|
||||
{session.revoked_at
|
||||
? new Date(session.revoked_at).toLocaleString()
|
||||
: "never"}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex flex-row items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="contained"
|
||||
className="bg-red-500 hover:bg-red-600 !px-1.5 !py-1.5"
|
||||
onClick={() => handleRevokeSession(session.id)}
|
||||
disabled={revokingId === session.id}
|
||||
>
|
||||
<Ban size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
onPageChange={(newPage) => fetchSessions(newPage)}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminServiceSessionsPage;
|
209
web/src/pages/Admin/UserSessions/index.tsx
Normal file
209
web/src/pages/Admin/UserSessions/index.tsx
Normal file
@ -0,0 +1,209 @@
|
||||
import Breadcrumbs from "@/components/ui/breadcrumbs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Avatar from "@/feature/Avatar";
|
||||
import type { DeviceInfo } from "@/types";
|
||||
import { Ban } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, type FC } from "react";
|
||||
import { Link } from "react-router";
|
||||
import moment from "moment";
|
||||
import Pagination from "@/components/ui/pagination";
|
||||
import { useUserSessions } from "@/store/admin/userSessions";
|
||||
import { useAuth } from "@/store/auth";
|
||||
|
||||
const SessionSource: FC<{ deviceInfo: string }> = ({ deviceInfo }) => {
|
||||
const parsed = useMemo<DeviceInfo>(
|
||||
() => JSON.parse(atob(deviceInfo)),
|
||||
[deviceInfo],
|
||||
);
|
||||
|
||||
return (
|
||||
<p>
|
||||
{parsed.os} {parsed.os_version} {parsed.browser} {parsed.browser_version}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
const AdminUserSessionsPage: FC = () => {
|
||||
const loading = useUserSessions((s) => s.loading);
|
||||
const sessions = useUserSessions((s) => s.items);
|
||||
|
||||
const page = useUserSessions((s) => s.page);
|
||||
const totalPages = useUserSessions((s) => s.totalPages);
|
||||
|
||||
const fetchSessions = useUserSessions((s) => s.fetch);
|
||||
const revokeSession = useUserSessions((s) => s.revoke);
|
||||
|
||||
const revokingId = useUserSessions((s) => s.revokingId);
|
||||
|
||||
const profile = useAuth((s) => s.profile);
|
||||
|
||||
const handleRevokeSession = useCallback(
|
||||
(id: string) => {
|
||||
revokeSession(id);
|
||||
},
|
||||
[revokeSession],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSessions(1);
|
||||
}, [fetchSessions]);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col items-stretch w-full">
|
||||
<div className="p-4">
|
||||
<Breadcrumbs
|
||||
className="pb-2"
|
||||
items={[
|
||||
{
|
||||
href: "/admin",
|
||||
label: "Admin",
|
||||
},
|
||||
{
|
||||
label: "User Sessions",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4 flex flex-row items-center justify-between">
|
||||
<p className="text-gray-800 dark:text-gray-300">Search...</p>
|
||||
{/* TODO: Filters */}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-x-auto">
|
||||
<table className="relative min-w-full border-l-0 border border-gray-300 dark:border-gray-700 border-collapse divide-y divide-gray-200 dark:divide-gray-800">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/60 dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<div className="text-gray-800 dark:text-gray-200 font-medium">
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<thead className="bg-black/5 dark:bg-white/5 text-nowrap">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
User
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Source
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Issued At
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Expires At
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Last Active
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Revoked At
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-300 dark:border-gray-700">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{!loading && sessions.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={5}
|
||||
className="px-6 py-12 text-center text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
No sessions found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sessions.map((session) => (
|
||||
<tr
|
||||
key={session.id}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
<td className="px-5 py-3 text-sm text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-700">
|
||||
<div className="flex flex-row items-center gap-2 justify-start">
|
||||
{typeof session.user?.profile_picture === "string" && (
|
||||
<Avatar
|
||||
avatarId={session.user.profile_picture}
|
||||
className="w-7 h-7 min-w-7"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Link to={`/admin/users/view/${session.user_id}`}>
|
||||
<p className="cursor-pointer text-blue-500 text-nowrap">
|
||||
{session.user?.full_name ?? ""}{" "}
|
||||
{session.user_id === profile?.id ? "(You)" : ""}
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-700">
|
||||
<SessionSource deviceInfo={session.device_info} />
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm border border-gray-300 dark:border-gray-700">
|
||||
<span
|
||||
className={`inline-block px-2 py-1 text-xs rounded-full font-semibold ${
|
||||
!session.is_active ||
|
||||
(session.expires_at &&
|
||||
moment(session.expires_at).isSameOrBefore(
|
||||
moment(new Date()),
|
||||
))
|
||||
? "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300"
|
||||
: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300"
|
||||
}`}
|
||||
>
|
||||
{session.is_active ? "Active" : "Inactive"}
|
||||
{moment(session.expires_at).isSameOrBefore(
|
||||
moment(new Date()),
|
||||
) && " (Expired)"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-700">
|
||||
{moment(session.issued_at).format("LLLL")}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-700">
|
||||
{session.expires_at
|
||||
? moment(session.expires_at).format("LLLL")
|
||||
: "never"}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-700">
|
||||
{session.last_active
|
||||
? moment(session.last_active).format("LLLL")
|
||||
: "never"}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-700">
|
||||
{session.revoked_at
|
||||
? new Date(session.revoked_at).toLocaleString()
|
||||
: "never"}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex flex-row items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="contained"
|
||||
className="bg-red-500 hover:bg-red-600 !px-1.5 !py-1.5"
|
||||
onClick={() => handleRevokeSession(session.id)}
|
||||
disabled={revokingId === session.id}
|
||||
>
|
||||
<Ban size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
onPageChange={(newPage) => fetchSessions(newPage)}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminUserSessionsPage;
|
@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { useUsers } from "@/store/admin/users";
|
||||
import { useCallback, type FC } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Link } from "react-router";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
|
||||
interface FormData {
|
||||
fullName: string;
|
||||
@ -24,17 +24,22 @@ const AdminCreateUserPage: FC = () => {
|
||||
|
||||
const createUser = useUsers((state) => state.createUser);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(data: FormData) => {
|
||||
async (data: FormData) => {
|
||||
console.log("Form submitted:", data);
|
||||
createUser({
|
||||
const success = await createUser({
|
||||
email: data.email,
|
||||
full_name: data.fullName,
|
||||
password: data.password,
|
||||
is_admin: data.isAdmin,
|
||||
});
|
||||
if (success) {
|
||||
navigate("/admin/users");
|
||||
}
|
||||
},
|
||||
[createUser],
|
||||
[createUser, navigate],
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -16,7 +16,7 @@ const AdminUsersPage: FC = () => {
|
||||
}, [fetchUsers]);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col items-stretch w-full h-full">
|
||||
<div className="relative flex flex-col items-stretch w-full">
|
||||
<div className="p-4">
|
||||
<Breadcrumbs
|
||||
className="pb-2"
|
||||
@ -76,7 +76,7 @@ const AdminUsersPage: FC = () => {
|
||||
colSpan={5}
|
||||
className="px-6 py-12 text-center text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
No services found.
|
||||
No users found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { useCallback, type FC } from "react";
|
||||
import { useCallback, useEffect, type FC } from "react";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ArrowLeftRight, ChevronDown } from "lucide-react";
|
||||
import { ArrowLeftRight, ChevronDown, LayoutDashboard } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Avatar from "@/feature/Avatar";
|
||||
import { useAuth } from "@/store/auth";
|
||||
@ -13,6 +13,12 @@ const AuthorizePage: FC = () => {
|
||||
|
||||
const profile = useAuth((state) => state.profile);
|
||||
|
||||
const fetchService = useOAuth((s) => s.fetchApiService);
|
||||
const fetching = useOAuth((s) => s.fetching);
|
||||
const clientId = useOAuth((s) => s.clientID);
|
||||
|
||||
const apiService = useOAuth((s) => s.apiService);
|
||||
|
||||
const selectSession = useOAuth((state) => state.selectSession);
|
||||
|
||||
const handleAgree = useCallback(() => {
|
||||
@ -20,12 +26,44 @@ const AuthorizePage: FC = () => {
|
||||
selectSession(activeAccount.access);
|
||||
}, [activeAccount, selectSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (clientId) {
|
||||
fetchService();
|
||||
}
|
||||
}, [clientId, fetchService]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative min-h-screen bg-cover bg-center bg-white dark:bg-black bg-[url(/overlay.jpg)] dark:bg-[url(/dark-overlay.jpg)]`}
|
||||
>
|
||||
<div className="relative z-10 flex items-center justify-center min-h-screen">
|
||||
<Card className="sm:w-[425px] sm:min-w-[425px] sm:max-w-96 sm:min-h-auto p-3 min-h-screen w-full min-w-full shadow-lg bg-white/65 dark:bg-black/65 backdrop-blur-md">
|
||||
<Card
|
||||
className={`relative sm:w-[425px] sm:min-w-[425px] sm:max-w-96 sm:min-h-auto min-h-screen w-full min-w-full shadow-lg bg-white/65 dark:bg-black/65 backdrop-blur-md ${!fetching ? "p-3" : ""}`}
|
||||
>
|
||||
{fetching && (
|
||||
<div
|
||||
role="status"
|
||||
className="absolute w-full h-full flex-1 flex items-center justify-center bg-black/25 dark:bg-white/25"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-12 h-12 text-gray-400 animate-spin fill-white"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col items-center pt-10 sm:pt-0">
|
||||
<div className="flex flex-col items-center flex-5/6">
|
||||
{/* <img
|
||||
@ -38,22 +76,27 @@ const AuthorizePage: FC = () => {
|
||||
<div className="text-gray-400 dark:text-gray-600">
|
||||
<ArrowLeftRight />
|
||||
</div>
|
||||
<div className="p-2 rounded-full bg-gray-900 ring ring-gray-400 dark:ring dark:ring-gray-500">
|
||||
<div className="w-12 h-12 rounded-full flex items-center justify-center overflow-hidden bg-gray-900 ring ring-gray-400 dark:ring dark:ring-gray-500">
|
||||
{/* <img
|
||||
src="https://lucide.dev/logo.dark.svg"
|
||||
className="w-8 h-8"
|
||||
/> */}
|
||||
{apiService?.icon_url ? (
|
||||
<img
|
||||
src="https://developer.mozilla.org/favicon.svg"
|
||||
className="w-8 h-8"
|
||||
src={apiService.icon_url}
|
||||
className="w-full h-full"
|
||||
alt="service_icon"
|
||||
/>
|
||||
) : (
|
||||
<LayoutDashboard size={32} color="#fefefe" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 sm:mt-4 mt-8">
|
||||
<h2 className="text-2xl font-medium text-gray-800 dark:text-gray-300 text-center w-full mb-2">
|
||||
<a href="#" className="text-blue-500">
|
||||
MDN Lab Services
|
||||
{apiService?.name ?? "Service"}
|
||||
</a>{" "}
|
||||
wants to access your Home Account
|
||||
</h2>
|
||||
@ -73,7 +116,7 @@ const AuthorizePage: FC = () => {
|
||||
<h4 className="text-base mb-3 text-gray-400 dark:text-gray-500 text-left">
|
||||
This will allow{" "}
|
||||
<a href="#" className="text-blue-500">
|
||||
MDN Lab Services
|
||||
{apiService?.name ?? "service"}
|
||||
</a>{" "}
|
||||
to:
|
||||
</h4>
|
||||
@ -83,23 +126,26 @@ const AuthorizePage: FC = () => {
|
||||
{/* <LogIn className="w-8 h-8 text-gray-700 mb-4" /> */}
|
||||
<CardContent className="w-full space-y-4 text-sm">
|
||||
<div className="flex flex-col gap-3 mb-8">
|
||||
{(apiService?.scopes?.length ?? 0) > 0 &&
|
||||
apiService!.scopes.map((scope) => (
|
||||
<div className="flex flex-row items-center justify-between text-gray-600 dark:text-gray-400">
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<div className="w-3 h-3 rounded-full bg-blue-500"></div>
|
||||
<p>View your full name, email and profile image</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-between text-gray-600 dark:text-gray-400">
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<div className="w-3 h-3 rounded-full bg-blue-500"></div>
|
||||
<p>View your permission from "MDN" group</p>
|
||||
<p>
|
||||
{scope === "openid" &&
|
||||
"Access your account id and use it"}
|
||||
{scope === "email" && "View your email address"}
|
||||
{scope === "profile" && "View your profile image"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-10">
|
||||
<p className="font-medium mb-4 dark:text-gray-200">
|
||||
Are you sure you want to trust MDN Lab Services?
|
||||
Are you sure you want to trust {apiService?.name ?? "service"}
|
||||
?
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||
Please do not share any sensitive, personal, or unnecessary
|
||||
|
@ -62,8 +62,9 @@ export default function LoginPage() {
|
||||
} catch (err: any) {
|
||||
console.log(err);
|
||||
setError(
|
||||
"Failed to create account. " +
|
||||
(err.message ?? "Unexpected error happened"),
|
||||
err.response?.data?.error ??
|
||||
err.message ??
|
||||
"Unexpected error happened",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { useVerify } from "@/store/verify";
|
||||
import { User } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState, type FC } from "react";
|
||||
import { Link } from "react-router";
|
||||
@ -16,6 +17,9 @@ const VerifyAvatarPage: FC = () => {
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const uploadAvatar = useVerify((s) => s.uploadAvatar);
|
||||
const uploading = useVerify((s) => s.uploading);
|
||||
|
||||
useEffect(() => {
|
||||
if (profile?.profile_picture) setAvatar(profile.profile_picture);
|
||||
}, [profile?.profile_picture]);
|
||||
@ -97,6 +101,17 @@ const VerifyAvatarPage: FC = () => {
|
||||
setTakingPicture(false);
|
||||
}, []);
|
||||
|
||||
const handleUpload = useCallback(async () => {
|
||||
if (!avatar) return;
|
||||
|
||||
const res = await fetch(avatar);
|
||||
const blob = await res.blob();
|
||||
|
||||
const file = new File([blob], "avatar.png", { type: blob.type });
|
||||
|
||||
uploadAvatar(file);
|
||||
}, [avatar, uploadAvatar]);
|
||||
|
||||
return (
|
||||
<div className="w-full sm:max-w-sm mx-auto p-4">
|
||||
<div className="flex flex-col gap-2 w-full max-w-xs mx-auto">
|
||||
@ -161,7 +176,14 @@ const VerifyAvatarPage: FC = () => {
|
||||
{avatar && (
|
||||
<>
|
||||
<Link to="/verify/review" className="w-full">
|
||||
<Button className="w-full">Next</Button>
|
||||
<Button
|
||||
className="w-full"
|
||||
loading={uploading}
|
||||
disabled={uploading}
|
||||
onClick={handleUpload}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
className="border-yellow-500 text-yellow-500 hover:border-yellow-600 hover:text-yellow-600"
|
||||
|
@ -1,9 +1,21 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { type FC } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { useVerify } from "@/store/verify";
|
||||
import { useCallback, useState, type FC } from "react";
|
||||
|
||||
const VerifyEmailOtpPage: FC = () => {
|
||||
const [otp, setOtp] = useState("");
|
||||
|
||||
const confirmOtp = useVerify((s) => s.confirmOTP);
|
||||
const confirming = useVerify((s) => s.confirming);
|
||||
|
||||
const handleVerify = useCallback(() => {
|
||||
if (otp.length !== 6) return;
|
||||
confirmOtp({
|
||||
otp,
|
||||
});
|
||||
}, [confirmOtp, otp]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-stretch gap-2 max-w-sm mx-auto p-4">
|
||||
<h1 className="text-xl font-medium dark:text-gray-200">
|
||||
@ -13,10 +25,22 @@ const VerifyEmailOtpPage: FC = () => {
|
||||
We've sent you verification code on your email address, please open your
|
||||
mailbox and enter the verification code in order to continue.
|
||||
</p>
|
||||
<Input placeholder="Enter OTP" />
|
||||
<Link to="/verify/avatar" className="w-full">
|
||||
<Button className="mt-3 w-full">Verify</Button>
|
||||
</Link>
|
||||
<Input
|
||||
placeholder="Enter OTP"
|
||||
value={otp}
|
||||
onChange={(e) => {
|
||||
e.preventDefault();
|
||||
setOtp(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
className="mt-3 w-full"
|
||||
onClick={handleVerify}
|
||||
loading={confirming}
|
||||
disabled={confirming}
|
||||
>
|
||||
Verify
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -1,12 +1,18 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { useVerify } from "@/store/verify";
|
||||
import maskEmail from "@/util/maskEmail";
|
||||
import { useCallback, useMemo, useState, type FC } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState, type FC } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
const VerifyEmailPage: FC = () => {
|
||||
const profile = useAuth((s) => s.profile);
|
||||
|
||||
const requestOtp = useVerify((s) => s.requestOTP);
|
||||
const requesting = useVerify((s) => s.requesting);
|
||||
const requested = useVerify((s) => s.requested);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
@ -17,9 +23,15 @@ const VerifyEmailPage: FC = () => {
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (matches) {
|
||||
requestOtp();
|
||||
}
|
||||
}, [matches, requestOtp]);
|
||||
|
||||
useEffect(() => {
|
||||
if (requested) {
|
||||
navigate("/verify/email/otp");
|
||||
}
|
||||
}, [matches, navigate]);
|
||||
}, [navigate, requested]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-stretch gap-2 sm:max-w-sm mx-auto p-4">
|
||||
@ -38,7 +50,8 @@ const VerifyEmailPage: FC = () => {
|
||||
<Button
|
||||
className={`mt-3 ${!matches ? "opacity-60" : ""}`}
|
||||
onClick={handleNext}
|
||||
disabled={!matches}
|
||||
disabled={!matches || requesting}
|
||||
loading={requesting}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
|
@ -1,12 +1,15 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import Avatar from "@/feature/Avatar";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import type { FC } from "react";
|
||||
import { useVerify } from "@/store/verify";
|
||||
import { type FC } from "react";
|
||||
|
||||
const VerifyReviewPage: FC = () => {
|
||||
const profile = useAuth((s) => s.profile);
|
||||
|
||||
const verifying = useVerify((s) => s.verifying);
|
||||
const finishVerify = useVerify((s) => s.verify);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-stretch gap-2 sm:max-w-sm mx-auto p-4">
|
||||
<h1 className="text-xl font-medium dark:text-gray-200">
|
||||
@ -29,7 +32,14 @@ const VerifyReviewPage: FC = () => {
|
||||
<p className="dark:text-gray-400 text-sm">{profile?.email}</p>
|
||||
</div>
|
||||
|
||||
<Button className="mt-4">Back Home</Button>
|
||||
<Button
|
||||
className="mt-4 w-full"
|
||||
loading={verifying}
|
||||
disabled={verifying}
|
||||
onClick={finishVerify}
|
||||
>
|
||||
Finish
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -1,12 +1,9 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
const VerifyStartPage: FC = () => {
|
||||
const profile = useAuth((s) => s.profile);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-5 w-full h-screen px-4 sm:px-0 sm:max-w-xl sm:h-auto text-center">
|
||||
<img src="/icon.png" className="w-16 h-16" alt="icon" />
|
||||
|
@ -22,7 +22,7 @@ interface IApiServicesState {
|
||||
|
||||
fetch: () => Promise<void>;
|
||||
fetchSingle: (id: string) => Promise<void>;
|
||||
create: (req: CreateApiServiceRequest) => Promise<void>;
|
||||
create: (req: CreateApiServiceRequest) => Promise<boolean>;
|
||||
resetCredentials: () => void;
|
||||
|
||||
toggling: boolean;
|
||||
@ -117,11 +117,12 @@ export const useApiServices = create<IApiServicesState>((set, get) => ({
|
||||
|
||||
try {
|
||||
const response = await postApiService(req);
|
||||
set({ createdCredentials: response.credentials });
|
||||
set({ createdCredentials: response.credentials, creating: false });
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log("ERR: Failed to fetch services:", err);
|
||||
} finally {
|
||||
set({ creating: false });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
59
web/src/store/admin/serviceSessions.ts
Normal file
59
web/src/store/admin/serviceSessions.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import {
|
||||
adminGetServiceSessionsApi,
|
||||
adminRevokeServiceSessionApi,
|
||||
} from "@/api/admin/sessions";
|
||||
import type { ServiceSession } from "@/types";
|
||||
import { create } from "zustand";
|
||||
|
||||
export const ADMIN_SERVICE_SESSIONS_PAGE_SIZE = 10;
|
||||
|
||||
export interface IServiceSessionsState {
|
||||
items: ServiceSession[];
|
||||
totalPages: number;
|
||||
page: number;
|
||||
|
||||
loading: boolean;
|
||||
|
||||
revokingId: string | null;
|
||||
|
||||
fetch: (page: number) => Promise<void>;
|
||||
revoke: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useServiceSessions = create<IServiceSessionsState>((set, get) => ({
|
||||
items: [],
|
||||
totalPages: 0,
|
||||
page: 1,
|
||||
loading: false,
|
||||
revokingId: null,
|
||||
|
||||
fetch: async (page) => {
|
||||
set({ loading: true, page });
|
||||
|
||||
try {
|
||||
const response = await adminGetServiceSessionsApi({
|
||||
page,
|
||||
size: ADMIN_SERVICE_SESSIONS_PAGE_SIZE,
|
||||
});
|
||||
set({ items: response.items, totalPages: response.total_pages });
|
||||
} catch (err) {
|
||||
console.log("ERR: Failed to fetch admin service sessions:", err);
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
revoke: async (id) => {
|
||||
set({ revokingId: id });
|
||||
|
||||
try {
|
||||
await adminRevokeServiceSessionApi(id);
|
||||
} catch (err) {
|
||||
console.log("ERR: Failed to revoke service sessions:", err);
|
||||
} finally {
|
||||
set({ revokingId: null });
|
||||
const { fetch, page } = get();
|
||||
await fetch(page);
|
||||
}
|
||||
},
|
||||
}));
|
59
web/src/store/admin/userSessions.ts
Normal file
59
web/src/store/admin/userSessions.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import {
|
||||
adminGetUserSessionsApi,
|
||||
adminRevokeUserSessionApi,
|
||||
} from "@/api/admin/sessions";
|
||||
import type { UserSession } from "@/types";
|
||||
import { create } from "zustand";
|
||||
|
||||
export const ADMIN_USER_SESSIONS_PAGE_SIZE = 10;
|
||||
|
||||
export interface IUserSessionsState {
|
||||
items: UserSession[];
|
||||
totalPages: number;
|
||||
page: number;
|
||||
|
||||
loading: boolean;
|
||||
|
||||
revokingId: string | null;
|
||||
|
||||
fetch: (page: number) => Promise<void>;
|
||||
revoke: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useUserSessions = create<IUserSessionsState>((set, get) => ({
|
||||
items: [],
|
||||
totalPages: 0,
|
||||
page: 1,
|
||||
loading: false,
|
||||
revokingId: null,
|
||||
|
||||
fetch: async (page) => {
|
||||
set({ loading: true, page });
|
||||
|
||||
try {
|
||||
const response = await adminGetUserSessionsApi({
|
||||
page,
|
||||
size: ADMIN_USER_SESSIONS_PAGE_SIZE,
|
||||
});
|
||||
set({ items: response.items, totalPages: response.total_pages });
|
||||
} catch (err) {
|
||||
console.log("ERR: Failed to fetch admin user sessions:", err);
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
revoke: async (id) => {
|
||||
set({ revokingId: id });
|
||||
|
||||
try {
|
||||
await adminRevokeUserSessionApi(id);
|
||||
} catch (err) {
|
||||
console.log("ERR: Failed to revoke user sessions:", err);
|
||||
} finally {
|
||||
set({ revokingId: null });
|
||||
const { fetch, page } = get();
|
||||
await fetch(page);
|
||||
}
|
||||
},
|
||||
}));
|
@ -15,7 +15,7 @@ export interface IUsersState {
|
||||
fetchingCurrent: boolean;
|
||||
|
||||
creating: boolean;
|
||||
createUser: (req: CreateUserRequest) => Promise<void>;
|
||||
createUser: (req: CreateUserRequest) => Promise<boolean>;
|
||||
|
||||
fetchUsers: () => Promise<void>;
|
||||
fetchUser: (id: string) => Promise<void>;
|
||||
@ -36,10 +36,12 @@ export const useUsers = create<IUsersState>((set) => ({
|
||||
try {
|
||||
const response = await postUser(req);
|
||||
console.log("INFO: User has been created:", response);
|
||||
set({ creating: false });
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log("ERR: Failed to create user:", err);
|
||||
} finally {
|
||||
set({ creating: false });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
@ -48,7 +50,7 @@ export const useUsers = create<IUsersState>((set) => ({
|
||||
|
||||
try {
|
||||
const response = await adminGetUsersApi();
|
||||
set({ users: response.items });
|
||||
set({ users: response.items ?? [] });
|
||||
} catch (err) {
|
||||
console.log("ERR: Failed to fetch users for admin:", err);
|
||||
} finally {
|
||||
|
@ -1,4 +1,6 @@
|
||||
import { getApiServiceCID } from "@/api/admin/apiServices";
|
||||
import { codeApi } from "@/api/code";
|
||||
import type { ApiService } from "@/types";
|
||||
import { create } from "zustand";
|
||||
|
||||
export interface OAuthState {
|
||||
@ -9,8 +11,13 @@ export interface OAuthState {
|
||||
state: string;
|
||||
nonce: string;
|
||||
|
||||
apiService: ApiService | null;
|
||||
fetching: boolean;
|
||||
|
||||
parseSearchParams: (params: URLSearchParams) => void;
|
||||
selectSession: (token: string) => Promise<void>;
|
||||
|
||||
fetchApiService: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useOAuth = create<OAuthState>((set, get) => ({
|
||||
@ -21,6 +28,25 @@ export const useOAuth = create<OAuthState>((set, get) => ({
|
||||
state: "",
|
||||
nonce: "",
|
||||
|
||||
apiService: null,
|
||||
fetching: false,
|
||||
|
||||
fetchApiService: async () => {
|
||||
const { clientID } = get();
|
||||
if (!clientID) return;
|
||||
|
||||
set({ fetching: true });
|
||||
|
||||
try {
|
||||
const response = await getApiServiceCID(clientID);
|
||||
set({ apiService: response });
|
||||
} catch (err) {
|
||||
console.log("ERR: Failed to fetch api service by client id:", err);
|
||||
} finally {
|
||||
set({ fetching: false });
|
||||
}
|
||||
},
|
||||
|
||||
parseSearchParams: (params) => {
|
||||
if (get().active) return;
|
||||
|
||||
@ -38,9 +64,9 @@ export const useOAuth = create<OAuthState>((set, get) => ({
|
||||
},
|
||||
|
||||
selectSession: async (token) => {
|
||||
const { active, redirectURI, nonce, state } = get();
|
||||
const { active, redirectURI, nonce, state, clientID } = get();
|
||||
if (active && redirectURI) {
|
||||
const codeResponse = await codeApi(token, nonce);
|
||||
const codeResponse = await codeApi(token, nonce, clientID);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
code: codeResponse.code,
|
||||
|
@ -1,17 +1,51 @@
|
||||
import { create } from "zustand";
|
||||
import { useAuth } from "./auth";
|
||||
import type { UserProfile } from "@/types";
|
||||
import {
|
||||
confirmEmailApi,
|
||||
finishVerificationApi,
|
||||
requestEmailOtpApi,
|
||||
type ConfirmEmailRequest,
|
||||
} from "@/api/verify";
|
||||
import { useAuth } from "./auth";
|
||||
import { uploadAvatarApi } from "@/api/avatar";
|
||||
|
||||
export type VerifyStep = "email" | "avatar" | "review";
|
||||
|
||||
export interface IVerifyState {
|
||||
step: VerifyStep | null;
|
||||
|
||||
loadStep: (profile: UserProfile) => void;
|
||||
step: VerifyStep | null | false;
|
||||
redirect: string | null;
|
||||
requesting: boolean;
|
||||
requested: boolean;
|
||||
confirming: boolean;
|
||||
uploading: boolean;
|
||||
verifying: boolean;
|
||||
}
|
||||
|
||||
export const useVerify = create<IVerifyState>((set) => ({
|
||||
export interface IVerifyActions {
|
||||
loadStep: (profile: UserProfile) => void;
|
||||
requestOTP: () => Promise<void>;
|
||||
confirmOTP: (req: ConfirmEmailRequest) => Promise<void>;
|
||||
uploadAvatar: (image: File) => Promise<void>;
|
||||
verify: () => Promise<void>;
|
||||
setRedirect: (redirect: string) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const initialState: IVerifyState = {
|
||||
step: null,
|
||||
redirect: null,
|
||||
|
||||
requesting: false,
|
||||
requested: false,
|
||||
confirming: false,
|
||||
uploading: false,
|
||||
verifying: false,
|
||||
};
|
||||
|
||||
export const useVerify = create<IVerifyState & IVerifyActions>((set, get) => ({
|
||||
...initialState,
|
||||
|
||||
reset: () => set(initialState),
|
||||
|
||||
loadStep: (profile) => {
|
||||
if (!profile.email_verified) {
|
||||
@ -29,6 +63,63 @@ export const useVerify = create<IVerifyState>((set) => ({
|
||||
return;
|
||||
}
|
||||
|
||||
set({ step: null });
|
||||
set({ step: false });
|
||||
},
|
||||
|
||||
requestOTP: async () => {
|
||||
set({ requesting: true, requested: false });
|
||||
|
||||
try {
|
||||
await requestEmailOtpApi();
|
||||
set({ requested: true });
|
||||
} catch (err) {
|
||||
console.log("ERR: Failed to request OTP:", err);
|
||||
} finally {
|
||||
set({ requesting: false });
|
||||
}
|
||||
},
|
||||
|
||||
confirmOTP: async (req: ConfirmEmailRequest) => {
|
||||
set({ confirming: true });
|
||||
|
||||
try {
|
||||
await confirmEmailApi(req);
|
||||
await useAuth.getState().authenticate();
|
||||
} catch (err) {
|
||||
console.log("ERR: Failed to request OTP:", err);
|
||||
} finally {
|
||||
set({ confirming: false });
|
||||
}
|
||||
},
|
||||
|
||||
uploadAvatar: async (image) => {
|
||||
set({ uploading: true });
|
||||
|
||||
try {
|
||||
await uploadAvatarApi(image);
|
||||
await useAuth.getState().authenticate();
|
||||
} catch (err) {
|
||||
console.log("ERR: Failed to request OTP:", err);
|
||||
} finally {
|
||||
set({ uploading: false });
|
||||
}
|
||||
},
|
||||
|
||||
setRedirect: (redirect) => {
|
||||
set({ redirect });
|
||||
},
|
||||
|
||||
verify: async () => {
|
||||
set({ verifying: true });
|
||||
|
||||
try {
|
||||
await finishVerificationApi();
|
||||
useAuth.getState().authenticate();
|
||||
} catch (err) {
|
||||
console.log("ERR: Failed to finish verification:", err);
|
||||
} finally {
|
||||
set({ verifying: false });
|
||||
get().reset();
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
@ -17,6 +17,7 @@ export interface ApiService {
|
||||
id: string;
|
||||
client_id: string;
|
||||
name: string;
|
||||
icon_url: string;
|
||||
description: string;
|
||||
redirect_uris: string[];
|
||||
scopes: string[];
|
||||
@ -30,3 +31,50 @@ export interface ApiServiceCredentials {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
}
|
||||
|
||||
export interface ServiceSession {
|
||||
id: string;
|
||||
service_id: string;
|
||||
api_service?: ApiService | null;
|
||||
client_id: string;
|
||||
user_id?: string | null;
|
||||
user?: UserProfile | null;
|
||||
issued_at: string;
|
||||
expires_at?: string | null;
|
||||
last_active?: string | null;
|
||||
ip_address?: string | null;
|
||||
user_agent?: string | null;
|
||||
access_token_id?: string | null;
|
||||
refresh_token_id?: string | null;
|
||||
is_active: boolean;
|
||||
revoked_at?: string | null;
|
||||
scope?: string | null;
|
||||
claims: string; // base64 encoded
|
||||
}
|
||||
|
||||
export interface UserSession {
|
||||
id: string;
|
||||
user_id: string;
|
||||
user?: UserProfile | null;
|
||||
session_type: string; // "user" | "admin"
|
||||
issued_at: string;
|
||||
expires_at?: string | null;
|
||||
last_active?: string | null;
|
||||
ip_address?: string | null;
|
||||
user_agent?: string | null;
|
||||
access_token_id?: string | null;
|
||||
refresh_token_id?: string | null;
|
||||
device_info: string; // base64 encoded
|
||||
is_active: boolean;
|
||||
revoked_at?: string | null;
|
||||
}
|
||||
|
||||
export interface DeviceInfo {
|
||||
os: string;
|
||||
os_version: string;
|
||||
device_name: string;
|
||||
device_type: string;
|
||||
location: string;
|
||||
browser: string;
|
||||
browser_version: string;
|
||||
}
|
||||
|
Reference in New Issue
Block a user