Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add audit log with email notification #26

Merged
merged 5 commits into from
Sep 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
PUBLIC_APP_URL=http://localhost
TRUST_PROXY=false
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ You're all set!
We use [Caddy](https://caddyserver.com) as a reverse proxy. You can use any other reverse proxy if you want but you have to configure it yourself.

#### Setup
Run `caddy run --config Caddyfile` in the root folder.
Run `caddy run --config reverse-proxy/Caddyfile` in the root folder.

### Testing

Expand Down
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ RUN CGO_ENABLED=1 GOOS=linux go build -o /app/backend/pocket-id-backend .
# Stage 3: Production Image
FROM node:20-alpine
RUN apk add --no-cache caddy
COPY ./Caddyfile /etc/caddy/Caddyfile
COPY ./reverse-proxy /etc/caddy/

WORKDIR /app
COPY --from=frontend-builder /app/frontend/build ./frontend/build
Expand All @@ -31,6 +31,7 @@ COPY --from=frontend-builder /app/frontend/package.json ./frontend/package.json

COPY --from=backend-builder /app/backend/pocket-id-backend ./backend/pocket-id-backend
COPY --from=backend-builder /app/backend/migrations ./backend/migrations
COPY --from=backend-builder /app/backend/email-templates ./backend/email-templates
COPY --from=backend-builder /app/backend/images ./backend/images

COPY ./scripts ./scripts
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ docker compose up -d
| Variable | Default Value | Recommended to change | Description |
| ---------------------- | ----------------------- | --------------------- | --------------------------------------------- |
| `PUBLIC_APP_URL` | `http://localhost` | yes | The URL where you will access the app. |
| `TRUST_PROXY` | `false` | yes | Whether the app is behind a reverse proxy. |
| `DB_PATH` | `data/pocket-id.db` | no | The path to the SQLite database. |
| `UPLOAD_PATH` | `data/uploads` | no | The path where the uploaded files are stored. |
| `INTERNAL_BACKEND_URL` | `http://localhost:8080` | no | The URL where the backend is accessible. |
Expand Down
119 changes: 119 additions & 0 deletions backend/email-templates/login-with-new-device.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: Arial, sans-serif;
background-color: #f0f0f0;
color: #333;
margin: 0;
padding: 0;
}
.container {
background-color: #fff;
color: #333;
padding: 32px;
border-radius: 10px;
max-width: 600px;
margin: 40px auto;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.header .logo {
display: flex;
align-items: center;
gap: 8px;
}
.header .logo img {
width: 32px;
height: 32px;
object-fit: cover;
}
.header h1 {
font-size: 1.5rem;
font-weight: bold;
}
.warning {
background-color: #ffd966;
color: #7f6000;
padding: 4px 12px;
border-radius: 50px;
font-size: 0.875rem;
}
.content {
background-color: #fafafa;
color: #333;
padding: 24px;
border-radius: 10px;
}
.content h2 {
font-size: 1.25rem;
font-weight: bold;
margin-bottom: 16px;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
.grid div {
display: flex;
flex-direction: column;
}
.grid p {
margin: 0;
}
.label {
color: #888;
font-size: 0.875rem;
margin-bottom: 4px;
}
.message {
font-size: 1rem;
line-height: 1.5;
}
</style>
<title>Pocket ID</title>
</head>
<body>

<div class="container">
<div class="header">
<div class="logo">
<img src="{{appUrl}}/api/application-configuration/logo" alt="Pocket ID" />
<h1>{{appName}}</h1>
</div>
<div class="warning">Warning</div>
</div>
<div class="content">
<h2>New Sign-In Detected</h2>
<div class="grid">
<div>
<p class="label">IP Address</p>
<p>{{ipAddress}}</p>
</div>
<div>
<p class="label">Device</p>
<p>{{device}}</p>
</div>
<div>
<p class="label">Sign-In Time</p>
<p>{{dateTimeString}}</p>
</div>
</div>
<p class="message">
This sign-in was detected from a new device or location. If you recognize this activity, you can safely ignore
this message. If not, please review your account and security settings.
</p>
</div>
</div>

</body>
</html>
1 change: 1 addition & 0 deletions backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ require (
github.com/golang-migrate/migrate/v4 v4.17.1
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/mileusna/useragent v1.3.4
golang.org/x/crypto v0.26.0
golang.org/x/time v0.6.0
gorm.io/driver/sqlite v1.5.6
Expand Down
2 changes: 2 additions & 0 deletions backend/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mileusna/useragent v1.3.4 h1:MiuRRuvGjEie1+yZHO88UBYg8YBC/ddF6T7F56i3PCk=
github.com/mileusna/useragent v1.3.4/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
Expand Down
10 changes: 6 additions & 4 deletions backend/internal/bootstrap/router_bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,14 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
r.Use(gin.Logger())

// Initialize services
webauthnService := service.NewWebAuthnService(db, appConfigService)
emailService := service.NewEmailService(appConfigService)
auditLogService := service.NewAuditLogService(db, appConfigService, emailService)
jwtService := service.NewJwtService(appConfigService)
webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService)
userService := service.NewUserService(db, jwtService)
oidcService := service.NewOidcService(db, jwtService)
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService)
testService := service.NewTestService(db, appConfigService)

// Add global middleware
r.Use(middleware.NewCorsMiddleware().Add())
r.Use(middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60))
r.Use(middleware.NewJwtAuthMiddleware(jwtService, true).Add(false))
Expand All @@ -45,10 +46,11 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {

// Set up API routes
apiGroup := r.Group("/api")
controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService, jwtService)
controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService)
controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService)
controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService)
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService)
controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware)

// Add test controller in non-production environments
if common.EnvConfig.AppEnv != "production" {
Expand Down
18 changes: 9 additions & 9 deletions backend/internal/controller/app_config_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ func NewAppConfigController(
acc := &AppConfigController{
appConfigService: appConfigService,
}
group.GET("/application-configuration", acc.listApplicationConfigurationHandler)
group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllApplicationConfigurationHandler)
group.PUT("/application-configuration", acc.updateApplicationConfigurationHandler)
group.GET("/application-configuration", acc.listAppConfigHandler)
group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler)
group.PUT("/application-configuration", acc.updateAppConfigHandler)

group.GET("/application-configuration/logo", acc.getLogoHandler)
group.GET("/application-configuration/background-image", acc.getBackgroundImageHandler)
Expand All @@ -36,8 +36,8 @@ type AppConfigController struct {
appConfigService *service.AppConfigService
}

func (acc *AppConfigController) listApplicationConfigurationHandler(c *gin.Context) {
configuration, err := acc.appConfigService.ListApplicationConfiguration(false)
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
configuration, err := acc.appConfigService.ListAppConfig(false)
if err != nil {
utils.ControllerError(c, err)
return
Expand All @@ -52,8 +52,8 @@ func (acc *AppConfigController) listApplicationConfigurationHandler(c *gin.Conte
c.JSON(200, configVariablesDto)
}

func (acc *AppConfigController) listAllApplicationConfigurationHandler(c *gin.Context) {
configuration, err := acc.appConfigService.ListApplicationConfiguration(true)
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
configuration, err := acc.appConfigService.ListAppConfig(true)
if err != nil {
utils.ControllerError(c, err)
return
Expand All @@ -68,14 +68,14 @@ func (acc *AppConfigController) listAllApplicationConfigurationHandler(c *gin.Co
c.JSON(200, configVariablesDto)
}

func (acc *AppConfigController) updateApplicationConfigurationHandler(c *gin.Context) {
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
var input dto.AppConfigUpdateDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err)
return
}

savedConfigVariables, err := acc.appConfigService.UpdateApplicationConfiguration(input)
savedConfigVariables, err := acc.appConfigService.UpdateAppConfig(input)
if err != nil {
utils.ControllerError(c, err)
return
Expand Down
56 changes: 56 additions & 0 deletions backend/internal/controller/audit_log_controller.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package controller

import (
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/middleware"
"net/http"
"strconv"

"github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
)

func NewAuditLogController(group *gin.RouterGroup, auditLogService *service.AuditLogService, jwtAuthMiddleware *middleware.JwtAuthMiddleware) {
alc := AuditLogController{
auditLogService: auditLogService,
}

group.GET("/audit-logs", jwtAuthMiddleware.Add(false), alc.listAuditLogsForUserHandler)
}

type AuditLogController struct {
auditLogService *service.AuditLogService
}

func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
userID := c.GetString("userID")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))

// Fetch audit logs for the user
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(userID, page, pageSize)
if err != nil {
utils.ControllerError(c, err)
return
}

// Map the audit logs to DTOs
var logsDtos []dto.AuditLogDto
err = dto.MapStructList(logs, &logsDtos)
if err != nil {
utils.ControllerError(c, err)
return
}

// Add device information to the logs
for i, logsDto := range logsDtos {
logsDto.Device = alc.auditLogService.DeviceStringFromUserAgent(logs[i].UserAgent)
logsDtos[i] = logsDto
}

c.JSON(http.StatusOK, gin.H{
"data": logsDtos,
"pagination": pagination,
})
}
4 changes: 2 additions & 2 deletions backend/internal/controller/oidc_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
return
}

code, callbackURL, err := oc.oidcService.Authorize(input, c.GetString("userID"))
code, callbackURL, err := oc.oidcService.Authorize(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
if err != nil {
if errors.Is(err, common.ErrOidcMissingAuthorization) {
utils.CustomControllerError(c, http.StatusForbidden, err.Error())
Expand All @@ -73,7 +73,7 @@ func (oc *OidcController) authorizeNewClientHandler(c *gin.Context) {
return
}

code, callbackURL, err := oc.oidcService.AuthorizeNewClient(input, c.GetString("userID"))
code, callbackURL, err := oc.oidcService.AuthorizeNewClient(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
if err != nil {
if errors.Is(err, common.ErrOidcInvalidCallbackURL) {
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
Expand Down
14 changes: 4 additions & 10 deletions backend/internal/controller/webauthn_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import (
"golang.org/x/time/rate"
)

func NewWebauthnController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, webauthnService *service.WebAuthnService, jwtService *service.JwtService) {
wc := &WebauthnController{webAuthnService: webauthnService, jwtService: jwtService}
func NewWebauthnController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, webauthnService *service.WebAuthnService) {
wc := &WebauthnController{webAuthnService: webauthnService}
group.GET("/webauthn/register/start", jwtAuthMiddleware.Add(false), wc.beginRegistrationHandler)
group.POST("/webauthn/register/finish", jwtAuthMiddleware.Add(false), wc.verifyRegistrationHandler)

Expand All @@ -32,7 +32,6 @@ func NewWebauthnController(group *gin.RouterGroup, jwtAuthMiddleware *middleware

type WebauthnController struct {
webAuthnService *service.WebAuthnService
jwtService *service.JwtService
}

func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
Expand Down Expand Up @@ -95,7 +94,8 @@ func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
}

userID := c.GetString("userID")
user, err := wc.webAuthnService.VerifyLogin(sessionID, userID, credentialAssertionData)

user, token, err := wc.webAuthnService.VerifyLogin(sessionID, userID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
if err != nil {
if errors.Is(err, common.ErrInvalidCredentials) {
utils.CustomControllerError(c, http.StatusUnauthorized, err.Error())
Expand All @@ -105,12 +105,6 @@ func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
return
}

token, err := wc.jwtService.GenerateAccessToken(user)
if err != nil {
utils.ControllerError(c, err)
return
}

var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
utils.ControllerError(c, err)
Expand Down
6 changes: 6 additions & 0 deletions backend/internal/dto/app_config_dto.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,10 @@ type AppConfigVariableDto struct {
type AppConfigUpdateDto struct {
AppName string `json:"appName" binding:"required,min=1,max=30"`
SessionDuration string `json:"sessionDuration" binding:"required"`
EmailEnabled string `json:"emailEnabled" binding:"required"`
SmtHost string `json:"smtpHost"`
SmtpPort string `json:"smtpPort"`
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
SmtpUser string `json:"smtpUser"`
SmtpPassword string `json:"smtpPassword"`
}
Loading
Loading