Back to Articles
Identity

Securing Go APIs with Ory Kratos

Identity and user management is the first and necessary part of creating a new application. There is a saying to not roll out your own auth, so I've settled on Ory Kratos for this website. In this post, we'll walk through how to get up and running using Kratos with Golang and HTML templates. Full code for the example is available at github.com/Hekewa/ory-kratos-go-example.

Ory Kratos

Kratos is an open source API-first identity and user management system. It will handle our:

  • Login
  • Logout
  • Registration
  • Account recovery (via email)

for us. There are a lot of more features Kratos is capable, but these are enough for now.

Run Kratos

We will run kratos using docker. Here is the docker-compose.yaml file to get it running (though read along before doing docker compose up as two configuration files are needed):

services:
  traefik:
    image: traefik:v3.6
    container_name: traefik
    command:
      - "--api.insecure=true"
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--entrypoints.web.address=:80"
      - "--providers.file.directory=/etc/traefik/dynamic"
    ports:
      - "8081:80"
      - "8080:8080"
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock:ro"
      - "./traefik_dynamic:/etc/traefik/dynamic"
    extra_hosts:
      - "host.docker.internal:host-gateway"
    networks:
      - intranet
  webserver:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: webserver
    labels:
      - "traefik.enable=true"
      - "traefik.http.services.webserver.loadbalancer.server.port=8001"
      - "traefik.http.routers.webserver.rule=PathPrefix(`/`)"
      - "traefik.http.routers.webserver.entrypoints=web"
      - "traefik.http.routers.webserver.priority=10"
    networks:
      - intranet
  kratos-migrate:
    image: oryd/kratos:v26.2.0
    container_name: kratos-migrate
    environment:
      - DSN=sqlite:///var/lib/sqlite/db.sqlite?_fk=true&mode=rwc
    volumes:
      - type: volume
        source: kratos-sqlite
        target: /var/lib/sqlite
        read_only: false
      - type: bind
        source: ./
        target: /etc/config/kratos
    command: -c /etc/config/kratos/kratos.yml migrate sql -e --yes
    restart: on-failure
    networks:
      - intranet
  kratos:
    depends_on:
      - kratos-migrate
    image: oryd/kratos:v26.2.0
    container_name: kratos
    ports:
      - '4433:4433' # public
      - '4434:4434' # admin
    restart: unless-stopped
    environment:
      - DSN=sqlite:///var/lib/sqlite/db.sqlite?_fk=true
    command: serve -c /etc/config/kratos/kratos.yml --dev --watch-courier
    volumes:
      - type: volume
        source: kratos-sqlite
        target: /var/lib/sqlite
        read_only: false
      - type: bind
        source: ./
        target: /etc/config/kratos
    networks:
      - intranet
    labels:
      - "traefik.enable=true"
      - "traefik.http.services.kratos.loadbalancer.server.port=4433"
      - "traefik.http.middlewares.kratos-strip.stripprefix.prefixes=/kratos"
      - "traefik.http.routers.kratos.entrypoints=web"
      - "traefik.http.routers.kratos.rule=PathPrefix(`/kratos`)"
      - "traefik.http.routers.kratos.middlewares=kratos-strip"
  mailslurper:
    image: oryd/mailslurper:latest-smtps
    container_name: mailslurper
    ports:
      - '4436:4436'
      - '4437:4437'
    networks:
      - intranet
networks:
  intranet:
volumes:
  kratos-sqlite:

This docker compose file will spin up a traefik reverse proxy for routing traffic to the actual webserver that should run on localhost:8081 and it will also route any traffic to localhost:8081/kratos to the kratos instance. There is also a mailslurper instance at localhost:4436 where you can check the account recovery emails sent by Kratos.

Next we need two setup files for Kratos itself. kratos.yaml which configures the Kratos server and identity.schema.json that defines the user account structure and flows.

dsn: memory

serve:
  public:
    base_url: http://localhost:8081/kratos
    cors:
      enabled: true
      allowed_origins:
        - http://localhost:8081
      allowed_methods:
        - POST
        - GET
        - PUT
        - PATCH
        - DELETE
      allowed_headers:
        - Authorization
        - Cookie
        - Content-Type
      exposed_headers:
        - Content-Type
        - Set-Cookie
  admin:
    base_url: http://localhost:4434/

selfservice:
  default_browser_return_url: http://localhost:8081/dashboard
  allowed_return_urls:
    - http://localhost:8081/welcome
    - http://localhost:8081/dashboard
    - http://localhost:8081

  methods:
    password:
      enabled: true
    totp:
      config:
        issuer: Kratos
      enabled: false
    lookup_secret:
      enabled: false
    link:
      enabled: true
    code:
      enabled: true

  flows:
    error:
      ui_url: http://localhost:8081/error

    settings:
      ui_url: http://localhost:8081/settings
      privileged_session_max_age: 15m
      required_aal: highest_available

    recovery:
      enabled: true
      ui_url: http://localhost:8081/recovery
      use: link

    verification:
      enabled: true
      ui_url: http://localhost:8081/verification
      use: code
      after:
        default_browser_return_url: http://localhost:8081/dashboard

    logout:
      after:
        default_browser_return_url: http://localhost:8081

    login:
      ui_url: http://localhost:8081/login
      lifespan: 10m

    registration:
      lifespan: 10m
      ui_url: http://localhost:8081/registration
      after:
        password:
          hooks:
            - hook: session

log:
  level: trace
  format: text
  leak_sensitive_values: true

secrets:
  cookie:
    - PLEASE-CHANGE-ME-I-AM-VERY-INSECURE
  cipher:
    - 32-LONG-SECRET-NOT-SECURE-AT-ALL

ciphers:
  algorithm: xchacha20-poly1305

hashers:
  algorithm: bcrypt
  bcrypt:
    cost: 8

identity:
  default_schema_id: default
  schemas:
    - id: default
      url: file:///etc/config/kratos/identity.schema.json

courier:
  smtp:
    connection_uri: smtps://test:test@mailslurper:1025/?skip_ssl_verify=true

Kratos works with "bring your own UI" principle, so we will need to implement the UI on each of the endpoints under the flows: section defined in this config file.

Lastly for Kratos setup we need to define the identity.schema.json that defines what data is required/optional for a user to fill in and how the user can login (email+password in our case).

{
"$id": "https://schemas.ory.sh/presets/kratos/quickstart/email-password/identity.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
  "traits": {
    "type": "object",
    "properties": {
      "email": {
        "type": "string",
        "format": "email",
        "title": "E-Mail",
        "minLength": 3,
        "ory.sh/kratos": {
          "credentials": {
            "password": {
              "identifier": true
            }
          },
          "verification": {
            "via": "email"
          },
          "recovery": {
            "via": "email"
          }
        }
      },
      "name": {
        "type": "object",
        "properties": {
          "first": {
            "title": "First Name",
            "type": "string"
          },
          "last": {
            "title": "Last Name",
            "type": "string"
          }
        }
      }
    },
    "required": [
      "email"
    ],
    "additionalProperties": false
  }
}
}

Kratos UI

Now that we have Kratos set up we need to implement the UI endpoints defined in kratos.yml. We will implement them with Golang webserver using text/html templates. I came up with this html template to convert the all of the Kratos frontend API responses to html forms:

<body class="kratos-body">
    <section class="kratos-container">
        {{ range .Messages }}
            <h2>{{ .Text }}</h2>
        {{ end }}
        <div class="kratos-card">
        <form class="kratos-form" id="kratos-form" action="{{ .Action }}" method="{{ .Method }}">
        {{ range .Nodes }}
        {{ if and .Attributes .Attributes.UiNodeInputAttributes }}
            {{ if ne .Attributes.UiNodeInputAttributes.Type "hidden" }}
            <div class="kratos-form-group">
                {{ if ne .Attributes.UiNodeInputAttributes.Type "submit"}}<label for="{{ .Attributes.UiNodeInputAttributes.Name }}">{{ .Meta.Label.Text }}</label>{{ end }}
            {{ else }}
            <div style="display: none;">
            {{ end }}
            {{ if eq .Attributes.UiNodeInputAttributes.Type "submit" "default"}}
                <button
                type="{{ .Attributes.UiNodeInputAttributes.Type }}"
                name="{{ .Attributes.UiNodeInputAttributes.Name }}"
                id="{{ .Attributes.UiNodeInputAttributes.Name }}"
                value="{{ .Attributes.UiNodeInputAttributes.Value }}"
                {{ if eq .Attributes.UiNodeInputAttributes.Value "previous" }}formnovalidate{{ end }}
                >{{ .Meta.Label.Text }}</button>
            {{ else }}
                <input
                type="{{ .Attributes.UiNodeInputAttributes.Type }}"
                name="{{ .Attributes.UiNodeInputAttributes.Name }}"
                id="{{ .Attributes.UiNodeInputAttributes.Name }}"
                value="{{ .Attributes.UiNodeInputAttributes.Value }}"
                {{ if .Attributes.UiNodeInputAttributes.Required }}required{{ end }}
                >
                <ul>
                {{ range .Messages }}
                <li class="kratos-node-error">{{ .Text }}</li>
                {{ end }}
                </ul>
            {{ end }}
            </div>
        {{ end }}
        {{ end }}
        </form>
        </div>
    </section>
</body>

With the template we can set up a golang webserver to handle the UI. All the handlers will look almost the same so we will focus on implementing the login UI.

func (a *App) Login(c echo.Context) error {
	flowId := c.FormValue("flow")
	req := a.ory.FrontendAPI.GetLoginFlow(c.Request().Context())
	req = req.Id(flowId)
	req = req.Cookie(c.Request().Header.Get("Cookie"))
	flow, _, err := req.Execute()
	if err != nil {
		return c.String(http.StatusInternalServerError, err.Error())
	}
	return c.Render(http.StatusOK, "kratos.html", flow.GetUi())
}

For the flow to start the user's browser need to get directed to kratos endpoint eg. to /kratos/self-service/login/browser. Kratos will then redirect to our login UI with a URL parameter login?flow=b690b1dd-8f83-4a26-83ea-52ecc721fb06 which our backend uses to fetch the data from Kratos and render the template. The form that the template renders will post its contents back to Kratos. With correct credentials Kratos sets up a session cookie and redirects to the default_browser_return_url from kratos.yml settings file.

Last we need to protect the our dashboard with a middleware so only logged in users can access it:

func (app *App) sessionMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
	return func(c echo.Context) error {
		cookies := c.Request().Header.Get("Cookie")

		session, _, err := app.ory.FrontendAPI.ToSession(c.Request().Context()).Cookie(cookies).Execute()
		if err != nil || !*session.Active {
			return c.String(http.StatusUnauthorized, "Unauthorized")
		}
		ctx := context.WithValue(c.Request().Context(), "req.session", session)
		c.SetRequest(c.Request().WithContext(ctx))
		return next(c)
	}
}

func getSession(ctx context.Context) (*ory.Session, error) {
	session, ok := ctx.Value("req.session").(*ory.Session)
	if !ok || session == nil {
		return nil, errors.New("session not found in context")
	}
	return session, nil
}

func (a *App) Dashboard(c echo.Context) error {
	sess, err := getSession(c.Request().Context())
	if err != nil {
		return c.String(http.StatusUnauthorized, "Unauthorized")
	}
	return c.Render(http.StatusOK, "dashboard.html", sess.Identity.GetTraits())
}

# Wrap the endpoint with the middleware in main function
	e.GET("/dashboard", app.sessionMiddleware(app.Dashboard))

With that we now have working user account management. This website also uses Kratos that is configured to an actual domain and connected to a proper email server. You can test it out yourself by cloning the github repo running docker compose up --build and going to http://localhost:8081 on your browser once it is done building.