- features
- project folders
- quickstart (dev)
- prerequisites
- dev notes
- production deployment (linux platform)
- how this project was built
-
Security
- Development with https self signed wildcard certs
- JWT auth flags
secure
,httponly
,samesite
strict - Roles
admin
,advanced
,normal
with static UserPermission matrix
-
Backend
- C# asp net core
- Configuration
- development user-secrets
- appsettings.json, appsettings.[Environment].json ( autoreload on change )
- environment variables
-
Frontend
- Typescript react frontend + vite tooling
- React redux
GlobalState
for current user - Layout with responsive appbar, public and protected pages with react router dom
- Openapi typescript/axios generate from backend swagger endpoint
- Login / Logout / Reset lost password through email link
- User manager with auth controller edit user to create, edit username, password, email, roles, disable
- Light/Dark themes, Snacks
- PWA capabilities ( installable w/chrome )
-
Debugging
- backend and frontend debugging in a solution from the same IDE
-
Production
- publish release with frontend webpacked available through server static files available directly from within backend
- publish deployment script with systemd service and environment secrets
folder | description |
---|---|
deploy | deploy files ( nginx dev and prod conf, scripts, systemd services, env secrets template ) |
doc | project documents, db. diagram |
misc | misc scripts ( restore scripts exec permissions, db dia gen script ) |
src | sources |
src/app/backend | c# app webapi backend |
src/app/frontend | react typescript frontend app |
libs/db-context | c# db context |
libs/db-migrations-psql | c# migrations project |
test | c# webapi tests |
- see prerequisites to setup self signed dev cert and nginx proxy
- clone repo
git clone https://github.com/devel0/example-webapp-with-auth.git
cd example-webapp-with-auth
dotnet new install .
cd ..
dotnet new webapp-with-auth -n project-folder --namespace My.Some
cd project-folder
source misc/restore-permissions.sh
dotnet build
- set shell variables replacing REPL_ vars
SEED_ADMIN_EMAIL=REPL_ADMIN_EMAIL
SEED_ADMIN_PASS="REPL_ADMIN_PASS"
DB_PROVIDER="Postgres"
DB_CONN_STRING="Host=localhost; Database=ExampleWebApp; Username=example_webapp_user; Password=$(cat ~/security/devel/ExampleWebApp/postgres-user)"
JWTKEY="$(openssl rand -hex 32)"
- set development user secrets
cd src/app/backend
dotnet user-secrets init
dotnet user-secrets set "SeedUsers:Admin:Email" "$SEED_ADMIN_EMAIL"
dotnet user-secrets set "SeedUsers:Admin:Password" "$SEED_ADMIN_PASS"
dotnet user-secrets set "DbProvider" "$DB_PROVIDER"
dotnet user-secrets set "ConnectionStrings:Main" "$DB_CONN_STRING"
dotnet user-secrets set "JwtSettings:Key" "$JWTKEY"
cd ..
- to be able to use the reset password feature configure also the smtp server
cd src/app/backend
dotnet user-secrets set "EmailServer:SmtpServerName" REPL_MAILSERVER_HOSTNAME
dotnet user-secrets set "EmailServer:SmtpServerPort" REPL_MAILSERVER_PORT
dotnet user-secrets set "EmailServer:Security" REPL_MAILSERVER_SECURITY
dotnet user-secrets set "EmailServer:Username" REPL_MAILSERVER_USER_EMAIL
dotnet user-secrets set "EmailServer:Password" REPL_MAILSERVER_USER_PASSWORD
cd ..
accepted values for EmailServer:Security
are Tls
, Ssl
, Auto
, None
.
code .
-
choose
.NET Core Launch (web)
from run and debug then hit F5 ( this will start asp net web server onhttps://webapp-test.searchathing.local/swagger/index.html
) -
restore client node modules
cd ../frontend
npm i
cd ../..
- start frontend
./run-frontend.sh
-
choose
Launch Chrome
from run and debug then click the play icon ( this will start browser ) -
try to login/current user/logout/current user button from frontend
-
login page
- master page
- user manager
- create db secrets
apt install pwgen
mkdir -p ~/security/devel/ExampleWebApp
chmod 700 ~/security
pwgen -s 12 -n 1 > ~/security/devel/postgres
echo "$(pwgen -s 12 -n 1)#" > ~/security/devel/ExampleWebApp/admin
pwgen -s 12 -n 1 > ~/security/devel/ExampleWebApp/postgres-user
echo "localhost:*:*:postgres:$(cat ~/security/devel/postgres)" >> ~/.pgpass
chmod 600 ~/.pgpass
- install postgres as docker and psql client in the host
docker volume create pgdata
docker run -e POSTGRES_PASSWORD=`cat ~/security/devel/postgres` --restart=unless-stopped --name postgres -v pgdata:/var/lib/postgresql/data -d -p 5432:5432/tcp postgres:latest
apt install postgresql-client-16
-
this will allow you to connect to localhost postgres db as postgres user ( test with
psql -h localhost -U postgres
if connects ) -
create postgres
example_webapp_user
user with capability to createdb -
local db setup
echo "CREATE USER example_webapp_user WITH PASSWORD '$(cat ~/security/devel/ExampleWebApp/postgres-user)' CREATEDB" | psql -h localhost -U postgres
- clone linux-scripts-utils
mkdir -p ~/opensource
cd ~/opensource
git clone [email protected]:devel0/linux-scripts-utils.git
export PATH=$PATH:~/opensource/linux-scripts-utils
mkdir -p ~/sscerts
chmod 700 ~/sscerts
- create cert parameters file
~/sscerts/searchathing.local.params
( replacesearchathing.local
with your own )
COUNTRY="IT"
STATE="Italy"
CITY="Trento"
ORGNAME="SearchAThing"
ORGUNIT="Development"
DOMAIN=localhost
DURATION_DAYS=36500 # 100 years
- create root-ca certificates
CERTPARAMS=~/sscerts/searchathing.local.params create-root-ca.sh
- generated root-ca files
file | description |
---|---|
~/sscerts/searchathing.local.crt |
root-ca certificate that you can register into the browser to trust linked certificates |
~/sscerts/searchathing.local.key |
key of the root-ca certificte ( this is NOT NEEDED anywhere, do not share ) |
- create test certificates ( note: this generate a wildcard certificate
*.yourdomain
, so can be reused for other development projects )
CERTPARAMS=~/sscerts/searchathing.local.params create-cert.sh --add-empty --add-wildcard
file | description |
---|---|
~/sscerts/searchathing.local/searchathing.local.crt |
is the certificate crt for nginx https proxy |
~/sscerts/searchathing.local/searchathing.local.key |
is the certificate key for nginx https proxy |
- install nginx
apt install nginx
- create
/etc/nginx/conf.d/dev-webapp-test.conf
by symlink webapp-test.conf
cd /etc/nginx/conf.d
ln -s PATH_TO/example-webapp-with-auth/deploy/nginx/dev/webapp-test.conf dev-webapp-test.conf
- edit
/etc/hosts
127.0.0.1 localhost
#-----------------------------------
# DEVELOPMENT
#-----------------------------------
127.0.0.1 dev-webapp-test.searchathing.local
Installing root-ca certificate imply that certificates generated within that will be consequently trusted.
-
chrome: settings/Privacy and security/Security/Manage certificates/Authorities/Import
- select
~/sscerts/searchathing.local_CA.crt
- tick
Trust this certificate for identifying websites
- select
-
firefox: settings/Privacy & Security/Certificates/View Certificates/Authorities/Import
- select
~/sscerts/searchathing.local_CA.crt
- tick
Trust this CA to identify websites
- select
-
shell
- copy
~/sscerts/searchathing.local_CA.crt
to/usr/local/share/ca-certificates
- issue
sudo update-ca-certificates
- copy
- authentication and authorization are managed entirely by the backend, in fact the frontend doesn't store any access token or restore token in local storage ; from the frontend side point of view the authentication is transparently managed through the browser
X-Access-Token
andX-Refresh-Token
that the server sets after successful login throughSet-Cookie
header ( the frontend only call the login and logout webapi without storing anything on javascript side ):- XSS ( Cross-site scripting ) attack are prevented because the absence of access token from the local storage makes javascript unable to read these token
- CSRF ( Cross-site request forgery ) attack are prevented because the cookie is stored within follow attributes
secure
: prevent the cookie to be stored against a phising site because https will identify the server autenticityhttponly
: prevent the javascript to read the cookie ( only the browser can handle by sending through the request header )samesite strict
: prevent to send the access token to other servers
- web api controller methods are executable only from user with valid access token because of the
[Authorize]
attribute ; further refinement can require user to have one or more roles through the attribute specialization withRoles
. To allow anonymous api use[AllowAnonymous]
attribute. - use of the access token allow the server to authenticate the user by reading user, role and other info contained in the token itself; note that these info are not encrypted and can be viewed, but the token contains a signature that can't be generated from other than the server that contains the JWT key to create the signature itself. In other words the server validate the access token and signature match considering as valid the provided identity informations ( because it was the server itself that signed the data no other could generate corresponding signature ). This requires less hardware resources than using a db to validate the user.
- for paranoid setting the expiration of an access token should short and this maintain ability to execute high rate operations retaining the ability to block a user within a short response time. In fact a valid access token can't revoked by default rule but having a short time of validity allow the server to ban any other authorized api for that user simply disabling it. In fact after user is disabled the process of renew of another access token, even with a valid refresh token ( that has longer expire time ) gets disabled immediately.
- in order to allow frontend application run longer than refresh token expiration, expecially if used a short refresh token ( ie. 5min ), the frontend will schedule a renew of refresh token, using the current valid auth, 30sec before the refresh token expires; this way the session continue without the need to login again. Following an excerpt with testing parameters ( AccessTokenDurationSeconds=10, RefreshTokenDurationSeconds=20, RefreshTokenDurationSkewSeconds=2 ) :
Layout.tsx:35 refresh token will expire at Sat Sep 07 2024 23:50:20 GMT+0200 (Central European Summer Time)
Layout.tsx:40 renew at Sat Sep 07 2024 23:50:10 GMT+0200 (Central European Summer Time)
Layout.tsx:42 renewing refresh token
Layout.tsx:35 refresh token will expire at Sat Sep 07 2024 23:50:30 GMT+0200 (Central European Summer Time)
Layout.tsx:40 renew at Sat Sep 07 2024 23:50:20 GMT+0200 (Central European Summer Time)
Layout.tsx:42 renewing refresh token
Layout.tsx:35 refresh token will expire at Sat Sep 07 2024 23:50:41 GMT+0200 (Central European Summer Time)
Layout.tsx:40 renew at Sat Sep 07 2024 23:50:31 GMT+0200 (Central European Summer Time)
Layout.tsx:42 renewing refr
In the frontend, by default a the renewal of refresh token happens 30 sec before expiration, but in this dev mode test it happens 10 sec before the expiration. Instead using provided appsettings.json the refresh token have a duration of 1200 sec ( 20 min ) and if the frontend is still opened at 20min - 30sec it will renew the refresh token ; this way an api call will issued each 19.5min to keep alive the authentication. Note, that if the user is disabled the renew refresh token gets unauthorized.
- configure unit test db settings
cd src/app/backend
TEST_DB_CONN_STRING="Host=localhost; Database=ExampleWebAppTest; Username=example_webapp_user; Password=$(cat ~/security/devel/ExampleWebApp/postgres-user)"
dotnet user-secrets set "ConnectionStrings:UnitTest" "$TEST_DB_CONN_STRING"
cd ../../..
- to run tests
dotnet test
or run specific test with ( replace TEST
with one from dotnet test -t
)
dotnet test --filter=TEST
param name | description | example |
---|---|---|
AppServerName | Used to build app url for the reset password link. | "dev-webapp-test.searchathing.local" |
DbProvider | Used to inject db provider service. | "Postgres" |
ConnectionStrings:Main | Used to build application db context datasource. | "Host=localhost; Database=ExampleWebApp; Username=postgres; Password=somepass" |
IsUnitTest | Used to build unit test application datasource in unit test mode. Will be set to true from the test factory. |
false |
ConnectionStrings:UnitTest | Need to be set in order to run unit tests. Warning: database referred by this conn string will be dropped during tests. | "Host=localhost; Database=ExampleWebAppTest; Username=postgres; Password=somepass" |
JwtSettings:Key | Symmetric key for JWT signature generation. | (results from openssl rand -hex 32 command) |
JwtSettings:Issuer | Issuer of the JWT access token. | "https://www.example.com" |
JwtSettings:Audience | Audience of the JWT access token. | "https://www.example.com/app" |
JwtSettings:AccessTokenDurationSeconds | JWT access token duration (seconds) | 300 |
JwtSettings:RefreshTokenDurationSeconds | JWT refresh token duration (seconds) | 1200 |
JwtSettings:ClockSkewSeconds | JWT access token clock skew (seconds) | 0 |
SeedUsers:Admin:UserName | Default seeded admin username | admin |
SeedUsers:Admin:Password | Default seeded admin password | SomePass1! |
SeedUsers:Admin:Email | Default seeded admin email | [email protected] |
EmailServer:Username | Email server config used in reset password ( account username ) | [email protected] |
EmailServer:Password | Email server config used in reset password ( account password ) | |
EmailServer:SmtpServerName | Email server config used in reset password ( account smtp server ) | [email protected] |
EmailServer:SmtpServerPort | Email server config used in reset password ( account smtp port ) | 587 |
EmailServer:Security | Email server config used in reset password ( account protocol security ) | "Tls" |
EmailServer:FromDisplayName | Email server config used in reset password ( account displayname of the sender ) | "Server" |
DbSchemaSnakeCase | if true generates db schema with snake case mode ( useful for Postgres ) |
The configuration is setup through SetupAppSettings method in order to evaluate:
appsettings.json
appsettings.ENV.json
( where ENV is the executing environment, ie.Development
,Production
, ... )- environment variables replacing
:
with__
( used for example in the production environment ) - user secrets used in development environment
The configuration of appsettings json files will reapplied on change automatically even at runtime ( note that in debug environment you need to change appsettings json files that are inside WebApiServer/bin/Debug/net8.0
folder )
./migr.sh add MIGRNAME
database diagram can be generated through gen-db-dia.sh script that uses schemacrawler ( more )
Configuration parameters for the frontend can be set at compile-time through .env.development and .env.production files depending on the build mode.
param name | description |
---|---|
VITE_SERVERNAME | used to build api url |
VITE_GITCOMMIT | git commit short sha |
VITE_GITCOMMITDATE | git commit date |
note that VITE_GITCOMMIT
and VITE_GITCOMMITDATE
gets automatically updated by the publish.sh script for the .env.production
configuration file.
- start the backend
cd example-webapp-with-auth/WebApiServer
dotnet run
- generate Typescript/axios frontend api
cd example-webapp-with-auth
./gen-api.sh
- browse through swagger interface ( avail in development environment ) ie. https://dev-webapp-test.searchathing.local/swagger
-
foreach ControlleBase api will generated through
gen-api.sh
-
create a related api reference ( example )
-
invoke with try, catch using handleApiException helper to report problem on the gui through snacks
try {
const res = await someApi.apiSomeGet({
param: value,
param2: value2,
})
console.log('successful api invocation')
} catch (_ex) {
handleApiException(_ex as ResponseError)
}
- on the login page there is a "Lost password ?" button
-
clicking on that button, having the email field filled with a previously registered user, cause the frontend to invoke the ResetLostPassword auth controller anonymous access api.
-
this controller api method in turn uses the authentication service ResetLostPasswordRequestAsync method; this works as follow
- retrieve existing user by given email
- retrieve configuration parameters for mail server
- retrieve configuration parameter for app servername in order to build a reset url like the follow
https://webapp-test.searchathing.local/app/Login/:from/RESET_TOKEN
(:from
parameter will considered null ) - email with reset password link sent
-
gui snack notification
- email received
- the mail link will open the browser at the login page with the token param and this cause the form to appears as follow
- inserting the corresponding email address now and a new password this will be reset through the call of the ResetLostPassword auth controller anonymous access api again but within non null token and resetPassword.
- then the authentication service
ResetLostPasswordRequestAsync
will finish the rule this way- execute the auth service
LoginAsync
with username, resetPassword and optional argument token with a non null value in order to reset the user passowrd
- execute the auth service
- if the user current user has permission to create user with some specific role use the
Create
button from the user manager
- to edit an existing user click on the
Edit
button
- these can be overriden at compile time here.
- the gui will inherit username and password rules through the AuthOptions service invoke by the sama name auth controller method. These will be evaluated during user editing here.
permission/role | admin | advanced | normal |
---|---|---|---|
ChangeUserRoles | ■ | ||
CreateAdminUser | ■ | ||
CreateAdvancedUser | ■ | ||
CreateNormalUser | ■ | ■ | |
ChangeOwnEmail | ■ | ■ | ■ |
ChangeOwnPassword | ■ | ■ | ■ |
ChangeNormalUserEmail | ■ | ■ | |
ChangeAdvancedUserEmail | ■ | ||
ChangeAdminUserEmail | ■ | ||
ResetNormalUserPassword | ■ | ■ | |
ResetAdvancedUserPassword | ■ | ||
ResetAdminUserPassword | ■ | ||
LockoutAdminUser | ■ | ||
LockoutAdvancedUser | ■ | ||
LockoutNormalUser | ■ | ■ | |
DeleteAdminUser | ■ | ||
DeleteAdvancedUser | ■ | ||
DeleteNormalUser | ■ | ■ | |
DisableAdminUser | ■ | ||
DisableAdvancedUser | ■ | ||
DisableNormalUser | ■ | ■ | |
ResetLostPassword | ■ | ■ | ■ |
dotnet publish -c Release --runtime linux-x64 --sc
-
note: option
--sc
makes self contained with all required runtimes ( ie. no need to install dotnet runtime on the target platform ) -
published files will be in
WebApiServer/bin/Release/net8.0/linux-x64/publish/
apt install postgres
su - postgres
psql
postgres=# CREATE USER webapp_test_user WITH ENCRYPTED PASSWORD 'DBPASS' CREATEDB;
CREATE ROLE
- tune postgres host allowed
/etc/postgresql/16/main/my.conf
listen_addresses = '*'
- tune postgres db permissions
/etc/postgresql/16/main/pg_hba.conf
( replaceTARGETMACHINEIP
with ip of the target machine where the app will run )
# TYPE DATABASE USER ADDRESS METHOD
host webapp_test webapp_test_user TARGETMACHINEIP/32 scram-sha-256
host postgres webapp_test_user TARGETMACHINEIP/32 scram-sha-256
Host main-test
HostName TARGETMACHINEIP
User root
IdentityFile ~/.ssh/main-test.id_rsa
- append
~/.ssh/main-test.id_rsa.pub
content to the target machine/root/.ssh/authorized_keys
- from target machine:
apt install openssh-server rsync nginx
useradd -m user
mkdir /root/secrets
the syntax is
./publish.sh argmuments:
-h <sshhost> ssh host where to publish ( ie. main-test )
-sn <servername> nginx app servername ( ie. mytest.searchathing.local )
-id <appid> app identifier ( ie. mytest )
-f force overwrite existing
then invoke
./publish.sh -h main-test -sn mytest.searchathing.local -id mytest
replace APP_ID with the one used in -id
publish parameter
-
edit
/root/security/APP_ID.env
replacing variables as described in commentsJwtSettings__Key
can be generated throughopenssl rand -hex 32
-
edit
/etc/system/systemd/APP_ID-webapp.service
replacing variables
variable | description |
---|---|
Description | service textual description |
SyslogIdentifier | service syslog identifier |
then issue service APP_ID-webapp restart
folder | description |
---|---|
/root/deploy/mytest | rsync of deploy folder |
/srv/mytest/bin | rsync of self contained production src/app/backend/bin/Release/net8.0/linux-x64/publish folder |
/etc/system/systemd/mytest-webapp.service | copy if not already exists of webapp.service |
/etc/nginx/conf.d/mytest-webapp.conf | copy if not already exists of webapp.conf |
-
started from clone from example web app
-
more frontend pkgs
mkdir example-webapp-with-auth
cd example-webapp-with-auth
git init
dotnet new gitignore
dotnet new webapi -n WebApiServer
npm create vite@latest clientapp -- --template react-ts
cd clientapp
npm i --save-dev @vitejs/plugin-react
npm i @mui/material @emotion/react @emotion/styled @mui/icons-material
npm i @reduxjs/toolkit react-redux react-router-dom axios linq-to-typescript usehooks-ts @fontsource/roboto
cd ..
dotnet new sln
dotnet sln add WebApiServer
dotnet build
- create app dbcontext
cd example-webapp-with-auth
dotnet new classlib -n AppDbContext
dotnet sln add AppDbContext
cd AppDbContext
dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore --version 8.0.5
dotnet add package Microsoft.EntityFrameworkCore.Design --version 8.0.5
- create app dbcontext migration
cd example-webapp-with-auth
dotnet new classlib -n AppDbMigrationsPsql
dotnet sln add AppDbMigrationsPsql
cd AppDbMigrationsPsql
dotnet add package Microsoft.EntityFrameworkCore.Relational --version 8.0.7
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL --version 8.0.4
dotnet add reference ../AppDbContext
- add db pkgs to webapi sever
cd example-webapp-with-auth
cd WebApiServer
dotnet add package Microsoft.EntityFrameworkCore.Design --version 8.0.7
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer --version 8.0.7
dotnet add package Microsoft.IdentityModel.Tokens --version 8.0.1
dotnet add package System.IdentityModel.Tokens.Jwt --version 8.0.1
dotnet add reference ../AppDbContext
dotnet add reference ../AppDbMigrationsPsql
- init ef tools
dotnet new tool-manifest
dotnet tool install dotnet-ef
- init secrets
openssl rand -hex 32 > ~/security/devel/ExampleWebApp/jwt.key
dotnet user-secrets init
dotnet user-secrets set "JwtSettings:Key" "$(cat ~/security/devel/ExampleWebApp/jwt.key)"
[email protected]
SEED_ADMIN_PASS=$(cat ~/security/devel/ExampleWebApp/admin)
DB_PROVIDER="Postgres"
DB_CONN_STRING="Host=localhost; Database=ExampleWebApp; Username=example_webapp_user; Password=$(cat ~/security/devel/ExampleWebApp/postgres-user)"
dotnet user-secrets set "SeedUsers:Admin:Email" "$SEED_ADMIN_EMAIL"
dotnet user-secrets set "SeedUsers:Admin:Password" "$SEED_ADMIN_PASS"
dotnet user-secrets set "DbProvider" "$DB_PROVIDER"
dotnet user-secrets set "ConnectionStrings:Main" "$DB_CONN_STRING"
-
coding... ( create app db context and models )
-
add initial migration
cd example-webapp-with-auth
cd WebApiServer
dotnet ef migrations add init --project ../AppDbMigrationsPsql -- --provider Postgres
- Add integration tests
cd example-webapp-with-auth
dotnet new xunit -n Test
cd Test
dotnet add package Microsoft.AspNetCore.Mvc.Testing --version 8.0.8