Skip to content

Add TOKEN authentication support with SSL enabled #159

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

Merged
merged 13 commits into from
Aug 10, 2021
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
4 changes: 2 additions & 2 deletions .github/workflows/npm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ jobs:
WECHATY_PUPPET_SERVICE_TOKEN: ${{ secrets.WECHATY_PUPPET_SERVICE_TOKEN }}

publish:
if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/heads/v'))
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/v'))
name: Publish
needs: [build, pack]
runs-on: ubuntu-latest
Expand All @@ -75,7 +75,7 @@ jobs:
- name: Check Branch
id: check-branch
run: |
if [[ ${{ github.ref }} =~ ^refs/heads/(master|v[0-9]+\.[0-9]+.*)$ ]]; then
if [[ ${{ github.ref }} =~ ^refs/heads/(main|v[0-9]+\.[0-9]+.*)$ ]]; then
echo ::set-output name=match::true
fi # See: https://stackoverflow.com/a/58869470/1123955
- name: Is A Publish Branch
Expand Down
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,41 @@ WECHATY_PUPPET_SERVICE_TOKEN=__WECHATY_PUPPET_SERVCIE_TOKEN__ node bot.js

## History

### master v0.25
### master v0.27

Implemented SSL and server-side token authorization!

#### New environment variables

For Puppet Server:

1. `WECHATY_PUPPET_SERVICE_SSL_SERVER_CERT`:
Server CA Cert (string data),
will be replaced if `options.sslServerCert` has been set.
1. `WECHATY_PUPPET_SERVICE_SSL_SERVER_KEY`:
Server CA Key (string data),
will be replaced if `optoins.sslServerKey` has been set.

For Puppet Client:

1. `WECHATY_PUPPET_SERVICE_AUTHORITY`:
`api.chatie.io` service discovery host name,
will be replaced if `options.authority` has been set.
1. `WECHATY_PUPPET_SERVICE_SSL_ROOT_CERT`:
Root CA Cert (string data),
will be replaced if `options.sslRootCert` has been set.
1. `WECHATY_PUPPET_SERVICE_GRPC_SSL_TARGET_NAME_OVERRIDE`:
Server Name (mast match for SNI),
will be replaced if `optoins.servername` has been set.

#### Changelog

1. use [wechaty-token](https://github.com/wechaty/token)
for gRPC service discovery with `wechaty` schema (xDS like)
1. deprecated `WECHATY_SERVICE_DISCOVERY_ENDPOINT`,
replaced by `WECHATY_PUPPET_SERVICE_AUTHORITY`.
(See [#156](https://github.com/wechaty/wechaty-puppet-service/issues/156))
1. enable TLS & Token Auth (See [#124](https://github.com/wechaty/wechaty-puppet-service/issues/124))

### v0.14 (Jan 2021)

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "wechaty-puppet-service",
"version": "0.25.2",
"version": "0.27.1",
"description": "Puppet Service for Wechaty",
"main": "dist/src/mod.js",
"typings": "dist/src/mod.d.ts",
Expand Down
10 changes: 10 additions & 0 deletions src/auth/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Auth for Wechaty Puppet Service gRPC

1. OpenSSL CA generation script: <https://github.com/wechaty/dotenv/blob/main/ca/generate.sh>
1. Common Name (CN) is very important because it will be used as Server Name Indication (SNI) in TLS handshake.

## Monkey Patch

We need to copy http2 header `:authority` to metadata.

See: <monkey-patch-header-authorization.ts>
81 changes: 81 additions & 0 deletions src/auth/auth-impl-token.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#!/usr/bin/env ts-node

import {
test,
sinon,
} from 'tstest'

import {
GrpcStatus,
Metadata,
UntypedServiceImplementation,
} from './grpc-js'

import { authImplToken } from './auth-impl-token'

test('authImplToken()', async t => {
const sandbox = sinon.createSandbox()
const spy = sandbox.spy()

const TOKEN = '__token__'
const IMPL: UntypedServiceImplementation = {
spy,
}

const implWithAuth = authImplToken(TOKEN)(IMPL)
const validMetadata = new Metadata()
validMetadata.add('Authorization', 'Wechaty ' + TOKEN)

const TEST_LIST = [
{ // Valid Token Request
call: {
metadata: validMetadata,
} as any,
callback: sandbox.spy(),
ok: true,
},
{ // Invalid request for Callback
call: {
metadata: new Metadata(),
} as any,
callback: sandbox.spy(),
ok: false,
},
{ // Invalid request for Stream
call: {
emit: sandbox.spy(),
metadata: new Metadata(),
} as any,
callback: undefined,
ok: false,
},
] as const

const method = implWithAuth['spy']!

for (const { call, callback, ok } of TEST_LIST) {
spy.resetHistory()

method(call, callback as any)

if (ok) {
t.true(spy.calledOnce, 'should call IMPL handler')
continue
}

/**
* not ok
*/
t.true(spy.notCalled, 'should not call IMPL handler')
if (callback) {
t.equal(callback.args[0]![0].code, GrpcStatus.UNAUTHENTICATED, 'should return UNAUTHENTICATED')
} else {
t.equal(call.emit.args[0][0], 'error', 'should emit error')
t.equal(call.emit.args[0][1].code, GrpcStatus.UNAUTHENTICATED, 'should emit UNAUTHENTICATED')
}

// console.info(spy.args)
// console.info(callback?.args)
}
sandbox.restore()
})
108 changes: 108 additions & 0 deletions src/auth/auth-impl-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { log } from '../config'

import {
StatusBuilder,
UntypedHandleCall,
sendUnaryData,
Metadata,
ServerUnaryCall,
GrpcStatus,
UntypedServiceImplementation,
} from './grpc-js'

import { monkeyPatchMetadataFromHttp2Headers } from './mokey-patch-header-authorization'

/**
* Huan(202108): Monkey patch to support
* copy `:authority` from header to metadata
*/
monkeyPatchMetadataFromHttp2Headers(Metadata)

/**
* Huan(202108): wrap handle calls with authorization
*
* See:
* https://grpc.io/docs/guides/auth/#with-server-authentication-ssltls-and-a-custom-header-with-token
*/
const authWrapHandlerToken = (validToken: string) => (handler: UntypedHandleCall) => {
log.verbose('wechaty-puppet-service',
'auth/auth-impl-token.ts authWrapHandlerToken(%s)(%s)',
validToken,
handler.name,
)

return function (
call: ServerUnaryCall<any, any>,
cb?: sendUnaryData<any>,
) {
// console.info('wrapAuthHandler internal')

const authorization = call.metadata.get('authorization')[0]
// console.info('authorization', authorization)

let errMsg = ''
if (typeof authorization === 'string') {
if (authorization.startsWith('Wechaty ')) {
const token = authorization.substring(8 /* 'Wechaty '.length */)
if (token === validToken) {

return handler(
call as any,
cb as any,
)

} else {
errMsg = `Invalid Wechaty TOKEN "${token}"`
}
} else {
const type = authorization.split(/\s+/)[0]
errMsg = `Invalid authorization type: "${type}"`
}
} else {
errMsg = 'No Authorization found.'
}

/**
* Not authorized
*/
const error = new StatusBuilder()
.withCode(GrpcStatus.UNAUTHENTICATED)
.withDetails(errMsg)
.withMetadata(call.metadata)
.build()

if (cb) {
/**
* Callback:
* handleUnaryCall
* handleClientStreamingCall
*/
cb(error)
} else if ('emit' in call) {
/**
* Stream:
* handleServerStreamingCall
* handleBidiStreamingCall
*/
call.emit('error', error)
} else {
throw new Error('no callback and call is not emit-able')
}
}
}

const authImplToken = <T extends UntypedServiceImplementation>(validToken: string) => (
serviceImpl: T,
) => {
log.verbose('wechaty-puppet-service', 'authImplToken()')

for (const [key, val] of Object.entries(serviceImpl)) {
// any: https://stackoverflow.com/q/59572522/1123955
(serviceImpl as any)[key] = authWrapHandlerToken(validToken)(val)
}
return serviceImpl
}

export {
authImplToken,
}
114 changes: 114 additions & 0 deletions src/auth/ca.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
#!/usr/bin/env ts-node

import {
test,
} from 'tstest'

import https from 'https'

import {
GET_WECHATY_PUPPET_SERVICE_SSL_ROOT_CERT,
GET_WECHATY_PUPPET_SERVICE_SSL_SERVER_CERT,
GET_WECHATY_PUPPET_SERVICE_SSL_SERVER_KEY,
} from './ca'
import { AddressInfo } from 'ws'

test('CA smoke testing', async t => {

const ca = GET_WECHATY_PUPPET_SERVICE_SSL_ROOT_CERT()
const cert = GET_WECHATY_PUPPET_SERVICE_SSL_SERVER_CERT()
const key = GET_WECHATY_PUPPET_SERVICE_SSL_SERVER_KEY()

const server = https.createServer({
cert,
key,
})

const ALIVE = 'Alive!\n'
const SNI = 'wechaty-puppet-service'

server.on('request', (_req, res) => {
res.writeHead(200)
res.end(ALIVE)
})

server.listen()
const port = (server.address() as AddressInfo).port

const reply = await new Promise((resolve, reject) => {
https.request({
ca,
hostname: '127.0.0.1',
method: 'GET',
path: '/',
port,
servername: SNI,
}, res => {
res.on('data', chunk => resolve(chunk.toString()))
res.on('error', reject)
}).end()
})
server.close()

t.equal(reply, ALIVE, 'should get https server reply')
})

test('CA SNI tests', async t => {

const ca = GET_WECHATY_PUPPET_SERVICE_SSL_ROOT_CERT()
const cert = GET_WECHATY_PUPPET_SERVICE_SSL_SERVER_CERT()
const key = GET_WECHATY_PUPPET_SERVICE_SSL_SERVER_KEY()

const server = https.createServer({
cert,
key,
})

server.on('request', (_req, res) => {
res.writeHead(200)
res.end(ALIVE)
})

server.listen()
const port = (server.address() as AddressInfo).port

const ALIVE = 'Alive!\n'
const SNI_TEST_LIST = [
[
'wechaty-puppet-service',
true,
],
[
'invalid-sni',
false,
"Hostname/IP does not match certificate's altnames: Host: invalid-sni. is not cert's CN: wechaty-puppet-service",
],
] as const

for (const [SNI, EXPECT, MSG] of SNI_TEST_LIST) {
const result = await new Promise((resolve, reject) => {
https.request({
ca,
hostname: '127.0.0.1',
method: 'GET',
path: '/',
port,
servername: SNI,
}, res => {
res.on('data', chunk => resolve(chunk.toString() === ALIVE))
res.on('error', reject)
})
.on('error', e => {
// console.info(e.message)
t.equal(e.message, MSG, 'should get the error for invalid SNI: ' + SNI)
resolve(false)
})
.end()

})

t.equal(result, EXPECT, 'should check the SNI: ' + SNI)
}

server.close()
})
Loading