Published on

ทำ Authentication ด้วย Nginx auth_request โมดูล

Authors

ทำ Authentication ด้วย Nginx auth_request โมดูล

ถ้าเรามี auth server และ resource server แยกออกจาก การที่เราจะป้องกันการเข้าถึง resource server นั้น วิธีการหนึ่งที่ใช้กัน คือ การเพิ่มโค้ดส่วนการทำ authentication เข้าไปในทุกๆ resource server ให้เรียกยัง auth server เพื่อทำการตรวจสอบ และรับผลลัพธ์การตรวจสอบมาทำงานต่อเอง แบบนี้

hexagonal

แต่เนื่องจากเราใช้ Nginx เป็น reverse proxy อยู่แล้ว ในบทความนี้จะมาแนะนำการใช้โมดูล auth_request ของ Nginx มาช่วยการทำ Authentication ให้แทน

hexagonal

โมดูล auth_request

วิธีการทำงานของโมดูลนี้ คือ มันมี request เข้ามายัง location ที่เราระบุไว้ Nginx จะทำการสร้าง HTTP subrequest ขึ้นมาเพื่อเรียกไปยัง auth server ของเรา โดยใช้ directives 2 ตัว คือ

  • auth_request - ใช้สำหรับระบุ URI ของ subrequest
  • auth_request_set - ใช้สำหรับสร้าง request variable หลังที่ authentication สำเร็จ จากตัวแปร $upstream_http_*

และเมื่อ auth server ตอบกลับมาจะทำการตรวจสอบจาก HTTP response code ของ subrequest นั้น ดังนี้

  • 2xx - จะอนุญาตเข้าถึง request ที่เรียกเข้ามาได้
  • 401, 403 - จะไม่อนุญาตเข้าถึง request ที่เรียกเข้ามา
  • สถานะอื่นๆ จะแสดงเป็น 500 Internal Server Error แทน

วิธีการใช้งาน

  1. กรณีใช้ Nginx เวอร์ชัน Open Source ให้ทำการตรวจสอบการว่ามีการติดตั้งโมดูล with-http_auth_request_module แล้วหรือไม่ โดยการรันคำสั่งนี้
nginx -V 2>&1 | grep -- 'http_auth_request_module'

ให้ดูว่าในข้อความที่แสดงนั้นมี --with-http_auth_request_module อยู่ด้วยหรือไม่

  1. สร้าง subrequest ใน request ที่ต้องการทำ authentication โดยใช้ directive auth_request
location /private/ {
  auth_request /auth;
  #...
}

จะทำให้ทุกครั้งที่เรียกมาที่ /private จะมีการสร้าง subrequest ส่งไปที่ /auth location

  1. สร้าง /auth ซึ่งจะสร้างเป็น internal location และใช้ directive proxy_pass ไปยัง authentication server หรือ service ของเรา
location = /auth {
  internal;
  proxy_pass http://auth-server;
  #...
}
  1. เนื่องจาก default จะ proxy_pass ไปยัง http://auth-server ด้วย method GET ดังนั้นไม่ต้องใช้ request body ในการ authentication ให้ปิดโดยใช้ directive proxy_pass_request_body กำหนดค่าเป็น off แล้วให้ Content-Length เป็นค่าว่าง
location = /auth {
  internal;
  proxy_pass              http://auth-server;
  proxy_pass_request_body off;
  proxy_set_header        Content-Length "";
  #...
}
  1. ถ้าต้องการส่ง original request URI ที่เรียกเข้าไปด้วย เช่น เอาไปทำ authorization ว่า user สามารถใช้งาน url นี้ได้หรือไม่ สามารถส่งไปได้โดยใช้ directive proxy_set_header
location = /auth {
  internal;
  proxy_pass              http://auth-server;
  proxy_pass_request_body off;
  proxy_set_header        Content-Length "";
  proxy_set_header        X-Original-URI $request_uri;
}
  1. เมื่อ authen ผ่านแล้ว เราสามารถส่งค่าต่อไปยังไปยัง request จริงของเราได้ โดยใช้ directive auth_request_set มาสร้างตัวแปรใหม่ขึ้นก่อน แล้วค่อยส่งต่อไป เช่น ส่ง username ไป
location /private/ {
  auth_request        /auth;
  # สร้างตัวแปรใหม่
  auth_request_set $auth_user $upstream_auth_user;
  # ส่งต่อไปทาง header
  proxy_set_header  X-User $auth_user;
}

นำมาใช้งานจริง

สมมุติว่าเรามี auth server และ resource server ดังนี้

  • auth server เอาไว้สำหรับการ signin เพื่อสร้าง token และ verify token

    const app = express()
    // สร้าง jwt สำหรับ admin หรือ user
    app.post('/api/auth/signin', (req, res) => {
      const isAdmin = req.query.isAdmin === 'true'
      const token = jwt.sign(
        {
          id: 1,
          username: isAdmin ? 'admin' : 'user',
          isAdmin,
        },
        secret
      )
      res.json({ token })
    })
    // จะทำการตรวจสอบ jwt และส่งข้อมูล user เป็น base64 ไปทาง X-Id-Token
    app.get('/api/auth/verify', (req, res) => {
      const token = req.headers.authorization ? req.headers.authorization.split(' ') : undefined
      if (!token || token.length < 2) return res.sendStatus(401)
      const user = jwt.verify(token[1], secret)
      res.set('X-Id-Token', Buffer.from(JSON.stringify(user)).toString('base64'))
      res.sendStatus(200)
    })
    
  • resource server จะมีการเอา user มาใช้งาน ซึ่งจะได้มาจากการ authen ผ่าน Nginx และส่งค่ามากับ X-Id-Token

    const app = express()
    // ใช้ middleware ในการ decode ข้อมูล user
    app.use((req, res, next) => {
      const idToken = req.headers['x-id-token']
      if (!idToken) return res.sendStatus(401)
      const decoded = Buffer.from(idToken, 'base64').toString('ascii')
      const user = JSON.parse(decoded)
      req.user = user
      next()
    })
    // เฉพาะ role admin เท่านั้น
    app.get('/api/resource/admin', (req, res) => {
      const user = req.user
      if (!user.isAdmin) return res.sendStatus(403)
      res.json({ message: `User "${user.username}" can access admin` })
    })
    
    app.get('/api/resource/private', (req, res) => {
      const user = req.user
      res.json({ message: `User "${user.username}" can access private` })
    })
    

ซึ่งเราจะเขียน nginx config ได้แบบนี้

upstream auth-api {
  server auth:3000;
}

upstream private-api {
  server private:3002;
}

server {
  listen       80;
  listen  [::]:80;
  server_name  _;

  # กำหนดให้ path verify ไม่สามารถเข้าจากภายนอกได้
  location ~ /api/auth/verify {
    deny all; # จะ return 403 กลับไป
  }

	# สำหรับ signin
  location ~ ^/(api/auth) {
    proxy_pass http://auth-api;

    proxy_redirect off;
    proxy_cache_bypass $http_upgrade;
    proxy_set_header Host $http_host; # proxy_set_header  Host $host;
    proxy_set_header X-NginX-Proxy true;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Host $server_name;
  }

  # สำหรับ auth_request
  location /auth-verify {
    internal;

    proxy_pass        http://auth-api/api/auth/verify;

    proxy_redirect    off;
    proxy_pass_request_body off;
    proxy_set_header Content-Length "";
    proxy_set_header X-Original-URI $request_uri;
    proxy_set_header Host $http_host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Host $server_name;
  }

  # ทุกครั้งที่เรียกมาที่นี่จะสร้าง subrequest ส่งไปยัง /auth-verify
  location ~ ^/(api/resource) {
    auth_request /auth-verify;
    # เอา x-id-token จาก subrequest มาส่งต่อ
    auth_request_set $x_id_token $upstream_http_x_id_token;

    proxy_pass http://private-api;

    proxy_redirect off;
    proxy_cache_bypass $http_upgrade;
    proxy_set_header Host $http_host; # proxy_set_header  Host $host;
    proxy_set_header X-NginX-Proxy true;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Host $server_name;
    proxy_set_header X-Id-Token $x_id_token; # ส่งไปกับ Header
  }

}

ทดสอบ

$ curl -XPOST \
'http://localhost:8800/api/signin'

{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJ1c2VyIiwiaXNBZG1pbiI6ZmFsc2UsImlhdCI6MTY2MTE3Nzg5NX0.rgcxfF00ck6zzYl4clNJBSPnDgrMVRYdlfl6E11nUKU"}

$ curl -XGET \
'http://localhost:8800/api/resource/private' \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJ1c2VyIiwiaXNBZG1pbiI6ZmFsc2UsImlhdCI6MTY2MTE3Nzg5NX0.rgcxfF00ck6zzYl4clNJBSPnDgrMVRYdlfl6E11nUKU"

{"message":"User \"user\" can access private"}

เพียงเท่านี้เราก็สามารถทำ authentication ผ่าน nginx ได้แล้ว

การจัดการ Error

เนื่องจากการใช้งานโมดูล auth_request ถ้า auth server ของเราไม่ได้ส่ง status code กลับมาเป็น 2xx ตัว nginx จะแสดง error ออกมาเป็นหน้า HTML ไม่ว่า auth server จะตอบกลับเป็นรูปแบบอื่นก็ตาม

$ curl -XGET \
'http://localhost:8800/api/resource/private' \
-H "Authorization: Bearer invalid_token"

<html>
<head><title>401 Authorization Required</title></head>
<body>
<center><h1>401 Authorization Required</h1></center>
<hr><center>nginx/1.23.1</center>
</body>
</html>

ซึ่งถ้าเราต้องการเป็นรูปแบบ JSON สามารถทำได้ดังนี้

server {

+ location @errorAuth401 {
+   default_type application/json;
+   return 401 '{"status":"401","error":{"code": 401,"message": "the token is invalid or expired"}}';
+ }

+ location @errorAuth403 {
+   default_type application/json;
+   return 403 '{"status":"403","error":{"code": 403,"message": "forbidden"}}';
+ }

+ location @errorAuth500 {
+   default_type application/json;
+   return 403 '{"status":"500","error":{"code": 500,"message": "unexpected error"}}';
+ }

  # ทุกครั้งที่เรียกมาที่นี่จะสร้าง subrequest ส่งไปยัง /auth-verify
  location ~ ^/(api/resource) {
    auth_request /auth-verify;

+   # จัดการ error
+   error_page 401 = @errorAuth401;
+   error_page 403 = @errorAuth403;
+   error_page 500 = @errorAuth500;

    # เอา x-id-token จาก subrequest มาส่งต่อ
    auth_request_set $x_id_token $upstream_http_x_id_token;

    proxy_pass http://private-api;

    proxy_redirect off;
    proxy_cache_bypass $http_upgrade;
    proxy_set_header Host $http_host; # proxy_set_header  Host $host;
    proxy_set_header X-NginX-Proxy true;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Host $server_name;
    proxy_set_header X-Id-Token $x_id_token; # ส่งไปกับ Header
  }

}

ทดสอบ error

$ curl -XGET \
'http://localhost:8800/api/resource/private' \
-H "Authorization: Bearer invalid_token"

{"status":"401","error":{"code": 401,"message": "the token is invalid or expired"}}

สรุป

วิธีการใช้โมดูล auth_request นั้น ช่วยให้เราไม่ต้องไปเขียนโค้ดในการเรียกไปยัง auth server เองในทุกๆ resource server ของเรา ซึ่งช่วยลดภาระงานไปได้ส่วนหนึ่ง และถ้าเราต้องการเพิ่มการทำ authorization ก็ทำได้ เพราะได้มีการส่ง url ของ request ปลายทางไปให้แล้ว แค่เพิ่มโค้ดตรวจสอบ permission เข้าไป ทำให้ตัดโค้ดส่วนนี้ออกจาก resource server ของเราออกไปได้อีกด้วย

สามารถดูโค้ดทั้งหมดได้ที่นี่