Skip to content

feat: Secure document sharing via Magic Link + OTP verification#22

Open
Laefuu wants to merge 1 commit intokOlapsis:mainfrom
Laefuu:feat/magiklink-share-and-otp
Open

feat: Secure document sharing via Magic Link + OTP verification#22
Laefuu wants to merge 1 commit intokOlapsis:mainfrom
Laefuu:feat/magiklink-share-and-otp

Conversation

@Laefuu
Copy link

@Laefuu Laefuu commented Mar 2, 2026

Why

Ackify produces reports for clients who need to read and acknowledge them. Currently there's no way to share a private document with a client who doesn't have an account, without exposing them through a public link or onboarding them through full SSO.

We need a lightweight, secure sharing mechanism that:

  • Doesn't require the client to create an account
  • Keeps the document private (not just "anyone with the link")
  • Lets the admin control and revoke access
  • Works with rotating clients (SSO onboarding is too heavy)

What

A new API-driven document sharing feature that combines magic links with one-time password (OTP) verification. Backend only — no UI changes.

User flow

  1. Admin calls the API to share a document with a client email
  2. System returns the OTP to the admin (shown once, never stored in cleartext) and emails the magic link to the client
  3. Admin communicates the OTP to the client through a separate channel (phone, SMS, in-person)
  4. Client clicks the magic link → sees an OTP prompt
  5. Client enters the OTP → gets a session and is redirected to the document
  6. The link remains usable (multi-use) as long as it's not expired or revoked
  7. Admin can list all shares for a document and revoke any of them

Security model

Layer Protection
Link alone Not enough — OTP is required
OTP 6-digit, hashed with bcrypt, never in emails, never stored in cleartext
Brute force 5 attempt limit, then permanent lockout
Error messages Generic — never reveal if token is revoked/locked/expired
Token exposure Admin listing endpoint does NOT return the magic link token value
Access control Create/list/revoke endpoints require admin authentication
Validity Configurable 1–90 days per share, default 7

How

Architecture

Built on top of the existing magic_link_tokens table and MagicLinkService, extending them with OTP and revocation capabilities:

graph TD
    A[Admin Handler] -->|CreateDocumentShareLink| B[AuthProvider Interface]
    B --> C[Dynamic Provider]
    C --> D[MagicLinkService]
    D --> E[MagicLinkRepository]
    E --> F[(magic_link_tokens table)]
    D --> G[Email Sender]

    H[Public Auth Handler] -->|ValidateDocumentShareToken| B
    H -->|VerifyDocumentShareOTP| B

    style A fill:#f9a825
    style H fill:#66bb6a
Loading

New API endpoints

Method Path Auth Purpose
POST /api/v1/admin/documents/{docId}/share Admin Create share → {otp, link}
GET /api/v1/admin/documents/{docId}/shares Admin List shares for a document
DELETE /api/v1/admin/documents/{docId}/share/{tokenId} Admin Revoke a share
GET /api/v1/auth/document-share/verify?token=... Public Validate token → {status: "otp_required"}
POST /api/v1/auth/document-share/verify-otp Public Submit OTP → session cookie + {redirect_to}

Database changes

Migration 0020_add_document_share_otp:

ALTER TABLE magic_link_tokens
  ADD COLUMN otp_hash TEXT,              -- bcrypt hash of 6-digit OTP
  ADD COLUMN otp_attempts INT DEFAULT 0, -- failed OTP attempts counter
  ADD COLUMN otp_max_attempts INT DEFAULT 5,
  ADD COLUMN revoked_at TIMESTAMPTZ,     -- NULL = active
  ADD COLUMN shared_by TEXT;             -- admin email who created the share

All columns are nullable/have defaults → zero impact on existing rows.

Files changed

New files

File Lines Purpose
migrations/0020_add_document_share_otp.up.sql 25 Schema migration
migrations/0020_add_document_share_otp.down.sql 14 Rollback
internal/presentation/api/admin/share_handler.go 125 Admin HTTP handlers
templates/document_share.html.tmpl 49 Email template (HTML)
templates/document_share.txt.tmpl 11 Email template (text)

Modified files

File What changed
pkg/models/magic_link.go +5 OTP fields, IsOTPLocked(), updated IsValid()
internal/infrastructure/database/magic_link_repository.go +3 methods (IncrementOTPAttempts, RevokeToken, ListByDocAndPurpose), updated CreateToken/GetByToken, fixed DeleteExpired
internal/application/services/magic_link_service.go +5 methods, removed custom min() that shadowed Go 1.21+ builtin
pkg/providers/interfaces.go +5 methods on AuthProvider, +DocumentShareInfo struct
pkg/web/auth/dynamic_provider.go Implemented 5 new methods
internal/presentation/api/auth/handler.go +2 public handlers
internal/presentation/api/router.go +5 routes
locales/en.json +14 i18n keys
locales/fr.json +14 i18n keys
locales/de.json +14 i18n keys
locales/es.json +14 i18n keys
locales/it.json +14 i18n keys
internal/presentation/api/auth/handler_test.go +5 mock methods
internal/presentation/api/shared/middleware.go Exempt /document-share/verify from strict API CSP

UI & Styling

  • Built a standalone HTML OTP page directly served by the backend (zero frontend deployment required)
  • Styled using Vanilla CSS to match the exact Ackify login theme (gradient, rounded corners, clean Google Fonts, lock icon)
  • Fully responsive with auto-advance digit boxes, copy-paste support, and spinner animations
  • Implemented a tailored, secure Content Security Policy (CSP) allowing only inline styles and specific font domains, while enabling connect-src 'self' for the frontend API fetch.

Dependencies

  • golang.org/x/crypto bumped (already indirect dependency, now used directly for bcrypt)

Side effects on existing code

Important

Carefully analyzed — no existing behavior is affected.

Concern Analysis Verdict
IsValid() change on login/reminder tokens New checks: RevokedAt (NULL → passes), IsOTPLocked() (OTPMaxAttempts=5 default, OTPAttempts=0 → false), UsedAt guard has Purpose != "document_share" → login/reminder still single-use Safe
DeleteExpired cleanup cron Added AND purpose != 'document_share' to unused-token cleanup. Without this, multi-use document shares (where used_at stays NULL) would be deleted after 7 days even if valid for 90 days. Login/reminder tokens unaffected. Fixed
CreateToken / GetByToken SQL changes New columns added to INSERT/SELECT. Old tokens have NULL/default values for new columns → queries work identically. Safe
AuthProvider interface 5 new methods. Any external implementation would fail to compile. This is a compile-time break, never a silent runtime failure. CE has one implementation (dynamic_provider.go) which is updated. Interface change

Testing

Unit tests

  • 18/18 pass in auth/handler_test.go (existing + updated mocks)

Live end-to-end test (against real PostgreSQL)

Step Input Result
Valid token → verify page GET /verify?token=<real> {"status":"otp_required"}
Wrong OTP POST /verify-otp {otp:"000000"} {"error":"Invalid or expired link or access code"}
Correct OTP POST /verify-otp {otp:"546947"} {"redirect_to":"/?doc=test-document-001"}
Re-use token GET /verify?token=<same> {"status":"otp_required"} (multi-use works)
Fake token GET /verify?token=fake {"error":"Invalid or expired link"}

Build verification

  • go build ./backend/... — clean compilation, zero warnings

What's next (not in this PR)

  • Frontend: Admin share management panel

…ngside new localization files and authentication middleware.
@btouchard
Copy link
Collaborator

Hello

Very interesting PR, thank you!
I'm already integrating the mechanism for guests.
I didn't go through an OTP, but it's clearly a good idea.
I'll take another look at your PR, I may not take it all, but if I can keep your mechanism for the OTP, I will.

In any case, thank you for your contribution!
PS: That's quite a lot of work, I'm surprised to see nothing else on your profile.

@Laefuu
Copy link
Author

Laefuu commented Mar 2, 2026

Hello,

You’re welcome! The tool is pretty neat. It’s just a feature we needed, so we tried to build it for our specific use case (which is why it mainly works through the API). It kind of turned into a challenge for me to try and create it in an decent way. Feel free to take anything you find useful from it, I thought it might be helpful.

I think OTP would be a great addition. It could be a good way to secure a "guest link" for sensitive content or allow users to log in safely.

As for my profile, I’ve removed a lot of my previous projects. I mainly used Git for university work, and I rarely push to my personal GitHub since I don’t often get the chance to work on side projects and dev the days haha. But I’m considering doing it more, and your code was a great opportunity to start.

Cheers, and thanks again for the tool :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants