From 1fa1d10acf8fedbc16b9c57150bf918a0532cd9b Mon Sep 17 00:00:00 2001 From: Rene Schallner Date: Sun, 16 Apr 2023 06:44:14 +0200 Subject: [PATCH] started adding HTTP auth + tests --- build.zig | 29 ++++++ build.zig.zon | 6 +- src/endpoint.zig | 88 ++++++++++++++++++ src/fio.zig | 1 + src/http_auth.zig | 220 ++++++++++++++++++++++++++++++++++++++++++++ src/http_client.zig | 65 +++++++++++++ src/test_auth.zig | 173 ++++++++++++++++++++++++++++++++++ src/zap.zig | 5 +- 8 files changed, 582 insertions(+), 5 deletions(-) create mode 100644 src/http_auth.zig create mode 100644 src/http_client.zig create mode 100644 src/test_auth.zig diff --git a/build.zig b/build.zig index 52a9403..fd56d07 100644 --- a/build.zig +++ b/build.zig @@ -82,4 +82,33 @@ pub fn build(b: *std.build.Builder) !void { const example_build_step = b.addInstallArtifact(example); example_step.dependOn(&example_build_step.step); } + + // http client for internal testing + var http_client_exe = b.addExecutable(.{ + .name = "http_client", + .root_source_file = .{ .path = "./src/http_client.zig" }, + .target = target, + .optimize = optimize, + }); + var http_client_step = b.step("http_client", "Build the http_client for internal testing"); + http_client_exe.linkLibrary(facil_dep.artifact("facil.io")); + http_client_exe.addModule("zap", zap_module); + const http_client_build_step = b.addInstallArtifact(http_client_exe); + http_client_step.dependOn(&http_client_build_step.step); + + // tests + const exe_tests = b.addTest(.{ + .root_source_file = .{ .path = "src/test_auth.zig" }, + .target = target, + .optimize = optimize, + }); + exe_tests.linkLibrary(facil_dep.artifact("facil.io")); + exe_tests.addModule("zap", zap_module); + exe_tests.step.dependOn(&http_client_build_step.step); + + // Similar to creating the run step earlier, this exposes a `test` step to + // the `zig build --help` menu, providing a way for the user to request + // running the unit tests. + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&exe_tests.step); } diff --git a/build.zig.zon b/build.zig.zon index abaa0e7..e5c48ef 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,11 +1,11 @@ .{ .name = "zap", - .version = "0.0.7", + .version = "0.0.8", .dependencies = .{ .@"facil.io" = .{ - .url = "https://github.com/zigzap/facil.io/archive/401acffd4398dacece5c167ecfc194ff1bf14665.tar.gz", - .hash = "1220109c8c8593ec18ed793482779aade4ce44643718742e772ab1acd43eac93574a", + .url = "https://github.com/zigzap/facil.io/archive/64a3fb6d66225d3ff6194e84623eb9d48bc3b6fb.tar.gz", + .hash = "1220da26a9450eb75ecdb93b5dd3dabfea53053734cb68c748f0426d445179bc7c92", } } } diff --git a/src/endpoint.zig b/src/endpoint.zig index 1c94210..1502434 100644 --- a/src/endpoint.zig +++ b/src/endpoint.zig @@ -1,5 +1,7 @@ const std = @import("std"); const zap = @import("zap.zig"); +const auth = @import("http_auth.zig"); + const Request = zap.SimpleRequest; const ListenerSettings = zap.SimpleHttpListenerSettings; const Listener = zap.SimpleHttpListener; @@ -49,6 +51,87 @@ pub const SimpleEndpoint = struct { } }; +/// Wrap an endpoint with an authenticator +pub fn AuthenticatingEndpoint(comptime Authenticator: type) type { + return struct { + authenticator: *Authenticator, + endpoint: *SimpleEndpoint, + auth_endpoint: SimpleEndpoint, + const Self = @This(); + + pub fn init(e: *SimpleEndpoint, authenticator: *Authenticator) Self { + return .{ + .authenticator = authenticator, + .endpoint = e, + .auth_endpoint = SimpleEndpoint.init(.{ + .path = e.settings.path, + // we override only the set ones. the other ones + // are set to null anyway -> will be nopped out + .get = if (e.settings.get != null) get else null, + .post = if (e.settings.post != null) post else null, + .put = if (e.settings.put != null) put else null, + .delete = if (e.settings.delete != null) delete else null, + }), + }; + } + + /// get the auth endpoint struct so we can be stored in the listener + /// when the listener calls the auth_endpoint, onRequest will have + /// access to all of us via fieldParentPtr + pub fn getEndpoint(self: *Self) *SimpleEndpoint { + return &self.auth_endpoint; + } + + /// here, the auth_endpoint will be passed in + pub fn get(e: *SimpleEndpoint, r: zap.SimpleRequest) void { + const authEp: *Self = @fieldParentPtr(Self, "auth_endpoint", e); + if (authEp.authenticator.authenticateRequest(&r) == false) { + // TODO: return 401 + std.debug.print("401\n", .{}); + return; + } + // auth successful + authEp.endpoint.settings.get.?(e, r); + } + + /// here, the auth_endpoint will be passed in + pub fn post(e: *SimpleEndpoint, r: zap.SimpleRequest) void { + const authEp: *Self = @fieldParentPtr(Self, "auth_endpoint", e); + if (authEp.authenticator.authenticateRequest(&r) == false) { + // TODO: return 401 + std.debug.print("401\n", .{}); + return; + } + // auth successful + authEp.endpoint.settings.post.?(e, r); + } + + /// here, the auth_endpoint will be passed in + pub fn put(e: *SimpleEndpoint, r: zap.SimpleRequest) void { + const authEp: *Self = @fieldParentPtr(Self, "auth_endpoint", e); + if (authEp.authenticator.authenticateRequest(&r) == false) { + // todo: return 401 + std.debug.print("401\n", .{}); + return; + } + // auth successful + authEp.endpoint.settings.put.?(e, r); + } + + /// here, the auth_endpoint will be passed in + pub fn delete(e: *SimpleEndpoint, r: zap.SimpleRequest) void { + const authEp: *Self = @fieldParentPtr(Self, "auth_endpoint", e); + if (authEp.authenticator.authenticateRequest(&r) == false) { + // todo: return 401 + std.debug.print("401\n", .{}); + return; + } + // auth successful + authEp.endpoint.settings.delete.?(e, r); + } + }; +} + pub const EndpointListenerError = error{ EndpointPathShadowError, }; @@ -74,6 +157,11 @@ pub const SimpleEndpointListener = struct { }; } + pub fn deinit(self: *Self) void { + _ = self; + endpoints.deinit(); + } + pub fn listen(self: *SimpleEndpointListener) !void { try self.listener.listen(); } diff --git a/src/fio.zig b/src/fio.zig index 6e68c55..6a79f8e 100644 --- a/src/fio.zig +++ b/src/fio.zig @@ -17,6 +17,7 @@ pub const struct_fio_start_args = extern struct { }; pub const fio_start_args = struct_fio_start_args; pub extern fn fio_start(args: struct_fio_start_args) void; +pub extern fn fio_stop() void; const struct_unnamed_37 = extern struct { vtbl: ?*anyopaque, flag: usize, diff --git a/src/http_auth.zig b/src/http_auth.zig new file mode 100644 index 0000000..5dea028 --- /dev/null +++ b/src/http_auth.zig @@ -0,0 +1,220 @@ +const std = @import("std"); +const zap = @import("zap.zig"); + +const AuthScheme = enum { + Basic, + Bearer, + + pub fn str(self: AuthScheme) []const u8 { + return switch (self) { + .Basic => "Basic ", + .Bearer => "Bearer ", + }; + } + + pub fn headerFieldStr(self: AuthScheme) []const u8 { + return switch (self) { + .Basic => "authentication", + .Bearer => "authorization", + }; + } +}; + +pub fn checkAuthHeader(scheme: AuthScheme, auth_header: []const u8) bool { + return switch (scheme) { + .Basic => |b| std.mem.startsWith(u8, auth_header, b.str()) and auth_header.len > b.str().len, + .Bearer => |b| std.mem.startsWith(u8, auth_header, b.str()) and auth_header.len > b.str().len, + }; +} + +pub fn extractAuthHeader(scheme: AuthScheme, r: *const zap.SimpleRequest) ?[]const u8 { + return switch (scheme) { + .Basic => |b| r.getHeader(b.headerFieldStr()), + .Bearer => |b| r.getHeader(b.headerFieldStr()), + }; +} + +const BasicAuthStrategy = enum { + /// decode into user and pass, then check pass + UserPass, + /// just look up the encoded user:pass token + Token68, +}; + +/// HTTP Basic Authentication RFC 7617 +/// "Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==" +/// user-pass strings: "$username:$password" -> base64 +/// +/// Notes: +/// - we only look at the Authentication header +/// - we ignore the required realm parameter +/// - we ignore the optional charset parameter +/// +/// Errors: +/// WWW-Authenticate: Basic realm="this" +/// +/// T : any kind of map that implements get([]const u8) -> []const u8 +pub fn BasicAuth(Lookup: type, kind: BasicAuthStrategy) type { + return struct { + // kind: BasicAuthStrategy, + allocator: std.mem.Allocator, + realm: ?[]const u8, + lookup: *Lookup, + + const Self = @This(); + + /// Creates a BasicAuth. `lookup` must implement `.get([]const u8) -> []const u8` + /// different implementations can + /// - either decode, lookup and compare passwords + /// - or just check for existence of the base64-encoded user:pass combination + /// if realm is provided (not null), a copy is taken -> call deinit() to clean up + pub fn init(allocator: std.mem.Allocator, lookup: *Lookup, realm: ?[]const u8) !Self { + return .{ + // .kind = kind, + .allocator = allocator, + .lookup = lookup, + .realm = if (realm) |the_realm| try allocator.dupe(u8, the_realm) else null, + }; + } + + pub fn deinit(self: *Self) void { + if (self.realm) |the_realm| { + self.allocator.free(the_realm); + } + } + + /// Use this to decode the auth_header into user:pass, lookup pass in lookup + pub fn authenticateUserPass(self: *Self, auth_header: []const u8) bool { + _ = auth_header; + _ = self; + // TODO + return false; + } + + /// Use this to just look up if the base64-encoded auth_header exists in lookup + pub fn authenticateToken68(self: *Self, auth_header: []const u8) bool { + _ = auth_header; + _ = self; + // TODO + return false; + } + + // dispatch based on kind + pub fn authenticate(self: *Self, auth_header: []const u8) bool { + // switch (self.kind) { + switch (kind) { + .UserPass => return self.authenticateUserPass(auth_header), + .Token68 => return self.authenticateToken68(auth_header), + } + } + pub fn authenticateRequest(self: *Self, r: *const zap.SimpleRequest) bool { + if (extractAuthHeader(.Bearer, r)) |auth_header| { + return self.authenticate(auth_header); + } + return false; + } + }; +} + +/// HTTP bearer authentication for a single token +/// RFC 6750 +/// "Authentication: Bearer TOKEN" +/// `Bearer` is case-sensitive +/// - we don't support form-encoded `access_token` body parameter +/// - we don't support URI query parameter `access_token` +/// +/// Errors: +/// HTTP/1.1 401 Unauthorized +/// WWW-Authenticate: Bearer realm="example", error="invalid_token", error_description="..." +pub const BearerAuthSingle = struct { + allocator: std.mem.Allocator, + token: []const u8, + realm: ?[]const u8, + + const Self = @This(); + + /// Creates a Single-Token Bearer Authenticator + /// takes a copy of the token + /// if realm is provided (not null), a copy is taken + /// call deinit() to clean up + pub fn init(allocator: std.mem.Allocator, token: []const u8, realm: ?[]const u8) !Self { + return .{ + .allocator = allocator, + .token = try allocator.dupe(u8, token), + .realm = if (realm) |the_realm| try allocator.dupe(u8, the_realm) else null, + }; + } + pub fn authenticate(self: *Self, auth_header: []const u8) bool { + if (checkAuthHeader(.Bearer, auth_header) == false) { + return false; + } + const token = auth_header[AuthScheme.Bearer.str().len..]; + return std.mem.eql(u8, token, self.token); + } + + pub fn authenticateRequest(self: *Self, r: *const zap.SimpleRequest) bool { + if (extractAuthHeader(.Bearer, r)) |auth_header| { + return self.authenticate(auth_header); + } + return false; + } + + pub fn deinit(self: *Self) void { + if (self.realm) |the_realm| { + self.allocator.free(the_realm); + } + self.allocator.free(self.token); + } +}; + +/// HTTP bearer authentication for multiple tokens +/// RFC 6750 +/// "Authentication: Bearer TOKEN" +/// `Bearer` is case-sensitive +/// - we don't support form-encoded `access_token` body parameter +/// - we don't support URI query parameter `access_token` +/// +/// Errors: +/// HTTP/1.1 401 Unauthorized +/// WWW-Authenticate: Bearer realm="example", error="invalid_token", error_description="..." +pub fn BearerAuthMulti(comptime T: type) type { + return struct { + allocator: std.mem.Allocator, + lookup: *T, + realm: ?[]const u8, + + const Self = @This(); + + /// Creates a BasicAuth. `lookup` must implement `.get([]const u8) -> []const u8` + /// to look up tokens + /// if realm is provided (not null), a copy is taken -> call deinit() to clean up + pub fn init(allocator: std.mem.Allocator, lookup: *T, realm: ?[]const u8) !Self { + return .{ + .allocator = allocator, + .lookup = lookup, + .realm = if (realm) |the_realm| try allocator.dupe(u8, the_realm) else null, + }; + } + + pub fn deinit(self: *Self) void { + if (self.realm) |the_realm| { + self.allocator.free(the_realm); + } + } + + pub fn authenticate(self: *Self, auth_header: []const u8) bool { + if (checkAuthHeader(.Bearer, auth_header) == false) { + return false; + } + const token = auth_header[AuthScheme.Bearer.str().len..]; + return self.lookup.*.contains(token); + } + + pub fn authenticateRequest(self: *Self, r: *const zap.SimpleRequest) bool { + if (extractAuthHeader(.Bearer, r)) |auth_header| { + return self.authenticate(auth_header); + } + return false; + } + }; +} diff --git a/src/http_client.zig b/src/http_client.zig new file mode 100644 index 0000000..f5ce37a --- /dev/null +++ b/src/http_client.zig @@ -0,0 +1,65 @@ +const std = @import("std"); +const zap = @import("zap.zig"); +const fio = @import("fio.zig"); +const util = @import("util.zig"); + +pub fn setHeader(h: [*c]fio.http_s, name: []const u8, value: []const u8) !void { + const hname: fio.fio_str_info_s = .{ + .data = util.toCharPtr(name), + .len = name.len, + .capa = name.len, + }; + + const vname: fio.fio_str_info_s = .{ + .data = util.toCharPtr(value), + .len = value.len, + .capa = value.len, + }; + const ret = fio.http_set_header2(h, hname, vname); + + if (ret == 0) return; + return zap.HttpError.HttpSetHeader; +} + +fn on_response(r: [*c]fio.http_s) callconv(.C) void { + if (r.*.status_str == zap.FIOBJ_INVALID) { + setHeader(r, "Authorization", "Bearer ABCDEFG") catch return; + zap.http_finish(r); + return; + } + + const response = zap.http_req2str(r); + if (zap.fio2str(response)) |body| { + std.debug.print("{s}\n", .{body}); + } else { + std.debug.print("Oops\n", .{}); + } + zap.fio_stop(); +} + +pub fn main() void { + const ret = zap.http_connect("http://127.0.0.1:3000/test", null, .{ + .on_response = on_response, + .on_request = null, + .on_upgrade = null, + .on_finish = null, + .udata = null, + .public_folder = null, + .public_folder_length = 0, + .max_header_size = 32 * 1024, + .max_body_size = 500 * 1024, + .max_clients = 1, + .tls = null, + .reserved1 = 0, + .reserved2 = 0, + .reserved3 = 0, + .ws_max_msg_size = 0, + .timeout = 5, + .ws_timeout = 0, + .log = 1, + .is_client = 1, + }); + // _ = ret; + std.debug.print("\nHTTP CONNECT ret = {d}\n", .{ret}); + zap.fio_start(.{ .threads = 1, .workers = 1 }); +} diff --git a/src/test_auth.zig b/src/test_auth.zig new file mode 100644 index 0000000..3531eb9 --- /dev/null +++ b/src/test_auth.zig @@ -0,0 +1,173 @@ +const std = @import("std"); +const Authenticators = @import("http_auth.zig"); +const zap = @import("zap.zig"); +const Endpoints = @import("endpoint.zig"); +const fio = @import("fio.zig"); +const util = @import("util.zig"); + +test "BearerAuthSingle authenticate" { + const a = std.testing.allocator; + const token = "hello, world"; + + var auth = try Authenticators.BearerAuthSingle.init(a, token, null); + defer auth.deinit(); + + // invalid auth header + try std.testing.expectEqual(auth.authenticate("wrong header"), false); + try std.testing.expectEqual(auth.authenticate("Bearer wrong-token"), false); + try std.testing.expectEqual(auth.authenticate("Bearer " ++ token), true); +} + +test "BearerAuthMulti authenticate" { + const a = std.testing.allocator; + const token = "hello, world"; + + var map = std.StringHashMap(void).init(a); // set + defer map.deinit(); + + try map.put(token, {}); + + var auth = try Authenticators.BearerAuthMulti(@TypeOf(map)).init(a, &map, null); + defer auth.deinit(); + + // invalid auth header + try std.testing.expectEqual(auth.authenticate("wrong header"), false); + try std.testing.expectEqual(auth.authenticate("Bearer wrong-token"), false); + try std.testing.expectEqual(auth.authenticate("Bearer " ++ token), true); +} + +const HTTP_RESPONSE: []const u8 = + \\ + \\ Hello from ZAP!!! + \\ +; +var received_response: []const u8 = "null"; + +fn endpoint_http_get(e: *Endpoints.SimpleEndpoint, r: zap.SimpleRequest) void { + _ = e; + r.sendBody(HTTP_RESPONSE) catch return; + received_response = HTTP_RESPONSE; + zap.fio_stop(); +} + +// +// http client code +// +fn setHeader(h: [*c]fio.http_s, name: []const u8, value: []const u8) !void { + const hname: fio.fio_str_info_s = .{ + .data = util.toCharPtr(name), + .len = name.len, + .capa = name.len, + }; + + const vname: fio.fio_str_info_s = .{ + .data = util.toCharPtr(value), + .len = value.len, + .capa = value.len, + }; + const ret = fio.http_set_header2(h, hname, vname); + + if (ret == 0) return; + return zap.HttpError.HttpSetHeader; +} + +fn sendRequest() void { + const ret = zap.http_connect("http://127.0.0.1:3000/test", null, .{ + .on_response = on_response, + .on_request = null, + .on_upgrade = null, + .on_finish = null, + .udata = null, + .public_folder = null, + .public_folder_length = 0, + .max_header_size = 32 * 1024, + .max_body_size = 500 * 1024, + .max_clients = 1, + .tls = null, + .reserved1 = 0, + .reserved2 = 0, + .reserved3 = 0, + .ws_max_msg_size = 0, + .timeout = 5, + .ws_timeout = 0, + .log = 0, + .is_client = 1, + }); + // _ = ret; + std.debug.print("\nret = {d}\n", .{ret}); + zap.fio_start(.{ .threads = 1, .workers = 1 }); +} + +fn on_response(r: [*c]fio.http_s) callconv(.C) void { + if (r.*.status_str == zap.FIOBJ_INVALID) { + setHeader(r, "Authorization", "Bearer ABCDEFG") catch return; + zap.http_finish(r); + return; + } + const response = zap.http_req2str(r); + if (zap.fio2str(response)) |body| { + std.debug.print("{s}\n", .{body}); + } else { + std.debug.print("Oops\n", .{}); + } + zap.fio_stop(); +} +// +// end of http client code +// + +test "BearerAuthSingle authenticateRequest" { + const a = std.testing.allocator; + const token = "ABCDEFG"; + + // spawn curl process before we start facilio threads + // unfortunately, this doesn't work: facilio doesn't start up if we spawn a child process + // var p = std.ChildProcess.init(&.{ "bash", "-c", "sleep 10; curl -H \"Authorization: Bearer\"" ++ token ++ " http://localhost:3000/test -v" }, a); + // try p.spawn(); + + // our custom client doesn't work either + // var p = std.ChildProcess.init(&.{ "bash", "-c", "sleep 3; ./zig-out/bin/http_client &" }, a); + // try p.spawn(); + // std.debug.print("done spawning\n", .{}); + + // this doesn't work either because facilio wants to be either server or client, gets confused doing it this way + // sendRequest(); + + // setup listener + var listener = zap.SimpleEndpointListener.init( + a, + .{ + .port = 3000, + .on_request = null, + .log = false, + .max_clients = 10, + .max_body_size = 1 * 1024, + }, + ); + defer listener.deinit(); + + // create mini endpoint + var ep = Endpoints.SimpleEndpoint.init(.{ .path = "/test", .get = endpoint_http_get }); + + // create authenticator + var authenticator = try Authenticators.BearerAuthSingle.init(a, token, null); + defer authenticator.deinit(); + + // create authenticating endpoint + var auth_ep = Endpoints.AuthenticatingEndpoint(@TypeOf(authenticator)).init(&ep, &authenticator); + + try listener.addEndpoint(auth_ep.getEndpoint()); + + listener.listen() catch {}; + std.debug.print("Listening on 0.0.0.0:3000\n", .{}); + std.debug.print("Please run the following:\n", .{}); + std.debug.print("./zig-out/bin/http_client", .{}); + + // start worker threads + zap.start(.{ + .threads = 1, + .workers = 0, + }); + + try std.testing.expectEqualStrings(HTTP_RESPONSE, received_response); +} diff --git a/src/zap.zig b/src/zap.zig index 3d62f70..01a46a0 100644 --- a/src/zap.zig +++ b/src/zap.zig @@ -189,6 +189,7 @@ pub const SimpleHttpListenerSettings = struct { port: usize, interface: [*c]const u8 = null, on_request: ?SimpleHttpRequestFn, + on_response: ?*const fn ([*c]fio.http_s) callconv(.C) void = null, public_folder: ?[]const u8 = null, max_clients: ?isize = null, max_body_size: ?usize = null, @@ -235,7 +236,7 @@ pub const SimpleHttpListener = struct { var x: fio.http_settings_s = .{ .on_request = if (self.settings.on_request) |_| Self.theOneAndOnlyRequestCallBack else null, .on_upgrade = null, - .on_response = null, + .on_response = self.settings.on_response, .on_finish = null, .udata = null, .public_folder = pfolder, @@ -312,7 +313,7 @@ pub fn listen(port: [*c]const u8, interface: [*c]const u8, settings: ListenSetti var x: fio.http_settings_s = .{ .on_request = settings.on_request, .on_upgrade = settings.on_upgrade, - .on_response = settings.on_response, + .on_response = settings.on_response orelse null, .on_finish = settings.on_finish, .udata = null, .public_folder = pfolder,