Revision | 9 (tree) |
---|---|
Time | 2020-10-05 14:02:06 |
Author | sebastiandotnet |
add extended jwt poc
@@ -1,8 +1,7 @@ | ||
1 | 1 | Motivation) |
2 | 2 | |
3 | -This is a proof of concept to proof a service-side interceptor is able to manage call level security. | |
4 | -To simplify this poc, i dont use JWT as session token here. This poc dont mean its a good idea | |
5 | -to use an interceptor for security. | |
3 | +This is a concept to proof a service-side interceptor is able to manage call level security. | |
4 | +This poc doesnt mean its a good idea to use an interceptor for security. Mostly standard policies works well. | |
6 | 5 | |
7 | 6 | |
8 | 7 | Ho it works) |
@@ -20,7 +19,7 @@ | ||
20 | 19 | (You can add credentials permanently to a channel of course.) |
21 | 20 | |
22 | 21 | The Common project in the middle is not directly referenced. Its just a shared place |
23 | -for ouer proto files. The client console app is a slightly cheap integration test. | |
22 | +for our proto files. The client console app is a slightly cheap integration test. | |
24 | 23 | |
25 | 24 | |
26 | 25 |
@@ -0,0 +1,15 @@ | ||
1 | +using System; | |
2 | +using System.Collections.Generic; | |
3 | +using System.Text; | |
4 | + | |
5 | +namespace SL.SessionJwt.ConsoleClient | |
6 | +{ | |
7 | + public static class Constants | |
8 | + { | |
9 | + public static readonly string TokenName = "Authorization"; | |
10 | + | |
11 | + public static readonly string BearerPrefix = "Bearer "; | |
12 | + | |
13 | + public static readonly string RemoteEndPoint = "https://localhost:5001"; | |
14 | + } | |
15 | +} |
@@ -0,0 +1,163 @@ | ||
1 | +using Grpc.Core; | |
2 | +using SL.SessionJwt.Service; | |
3 | +using System; | |
4 | +using System.Runtime.CompilerServices; | |
5 | +using System.Threading.Tasks; | |
6 | + | |
7 | +namespace SL.SessionJwt.ConsoleClient | |
8 | +{ | |
9 | + public class OutdatedTookie | |
10 | + { | |
11 | + public async Task Start() | |
12 | + { | |
13 | + WriteLine($"=== {nameof(OutdatedTookie)} starts. ==="); | |
14 | + | |
15 | + var user = await LogonAndSayHello(); | |
16 | + await Wait1MinuteForTokenTimeout(); | |
17 | + await SayHelloAndFail(user); | |
18 | + await RenewTokenAndSayHello(user); | |
19 | + await Logout(user, true); | |
20 | + await SayHelloAndFail(user); | |
21 | + await RenewAndFailBecauseExplicitLogout(user); | |
22 | + | |
23 | + user = await LogonAndSayHello(); | |
24 | + await Wait2MinutesForTokenTimeoutNotRenewable(); | |
25 | + await RenewAndFailBecauseNotRenewable(user); | |
26 | + await Logout(user); | |
27 | + | |
28 | + WriteLine($"=== {nameof(OutdatedTookie)} passed. ==={Environment.NewLine}"); | |
29 | + } | |
30 | + | |
31 | + public async Task<UserProxy> LogonAndSayHello() | |
32 | + { | |
33 | + var user = new UserProxy("User1"); | |
34 | + try | |
35 | + { | |
36 | + await user.LogonAsync(); | |
37 | + await user.Client<Greeter.GreeterClient>().SayHelloAsync(new HelloRequest { Name = nameof(OutdatedTookie) }); | |
38 | + WriteLine("passed."); | |
39 | + return user; | |
40 | + } | |
41 | + catch (RpcException exception) | |
42 | + { | |
43 | + if (exception.StatusCode != StatusCode.InvalidArgument) | |
44 | + WriteLine($"RpcException {exception.StatusCode}"); | |
45 | + throw; | |
46 | + } | |
47 | + catch (Exception exception) | |
48 | + { | |
49 | + WriteLine($"Exception {exception.Message}"); | |
50 | + throw; | |
51 | + } | |
52 | + } | |
53 | + | |
54 | + public static async Task Wait1MinuteForTokenTimeout() | |
55 | + { | |
56 | + Console.WriteLine("Wait a minute."); | |
57 | + await Task.Delay(61000); | |
58 | + } | |
59 | + | |
60 | + public static async Task Wait2MinutesForTokenTimeoutNotRenewable() | |
61 | + { | |
62 | + Console.WriteLine("Wait 2 minutes."); | |
63 | + await Task.Delay(121000); | |
64 | + } | |
65 | + | |
66 | + public static async Task SayHelloAndFail(UserProxy user) | |
67 | + { | |
68 | + try | |
69 | + { | |
70 | + await user.Client<Greeter.GreeterClient>().SayHelloAsync(new HelloRequest { Name = nameof(OutdatedTookie) }); | |
71 | + } | |
72 | + catch (RpcException) | |
73 | + { | |
74 | + WriteLine("passed."); | |
75 | + return; | |
76 | + } | |
77 | + catch (Exception exception) | |
78 | + { | |
79 | + WriteLine($"{nameof(SayHelloAndFail)} Unexpected Exception {exception.Message}"); | |
80 | + throw; | |
81 | + } | |
82 | + throw new Exception("This should go wrong."); | |
83 | + } | |
84 | + | |
85 | + public static async Task RenewTokenAndSayHello(UserProxy user) | |
86 | + { | |
87 | + try | |
88 | + { | |
89 | + await user.RenewAsync(); | |
90 | + await user.Client<Greeter.GreeterClient>().SayHelloAsync(new HelloRequest { Name = nameof(OutdatedTookie) }); | |
91 | + WriteLine("passed."); | |
92 | + } | |
93 | + catch (RpcException exception) | |
94 | + { | |
95 | + if (exception.StatusCode != StatusCode.InvalidArgument) | |
96 | + WriteLine($"RpcException {exception.StatusCode}"); | |
97 | + throw; | |
98 | + } | |
99 | + catch (Exception exception) | |
100 | + { | |
101 | + WriteLine($"Exception {exception.Message}"); | |
102 | + throw; | |
103 | + } | |
104 | + } | |
105 | + | |
106 | + public static async Task Logout(UserProxy user, bool keepToken = false) | |
107 | + { | |
108 | + try | |
109 | + { | |
110 | + await user.LogoutAsync(keepToken); | |
111 | + WriteLine("passed."); | |
112 | + } | |
113 | + catch (Exception exception) | |
114 | + { | |
115 | + Console.WriteLine($"Logout failed. {exception.Message}"); | |
116 | + throw; | |
117 | + } | |
118 | + } | |
119 | + | |
120 | + public static async Task RenewAndFailBecauseExplicitLogout(UserProxy user) | |
121 | + { | |
122 | + try | |
123 | + { | |
124 | + await user.RenewAsync(); | |
125 | + } | |
126 | + catch (RpcException) | |
127 | + { | |
128 | + WriteLine("passed."); | |
129 | + return; | |
130 | + } | |
131 | + catch (Exception exception) | |
132 | + { | |
133 | + WriteLine($"{nameof(RenewAndFailBecauseExplicitLogout)} Unexpected Exception {exception.Message}"); | |
134 | + throw; | |
135 | + } | |
136 | + throw new Exception("This should go wrong."); | |
137 | + } | |
138 | + | |
139 | + public static async Task RenewAndFailBecauseNotRenewable(UserProxy user) | |
140 | + { | |
141 | + try | |
142 | + { | |
143 | + await user.RenewAsync(); | |
144 | + } | |
145 | + catch (RpcException) | |
146 | + { | |
147 | + WriteLine("passed."); | |
148 | + return; | |
149 | + } | |
150 | + catch (Exception exception) | |
151 | + { | |
152 | + WriteLine($"{nameof(RenewAndFailBecauseNotRenewable)} Unexpected Exception {exception.Message}"); | |
153 | + throw; | |
154 | + } | |
155 | + throw new Exception("This should go wrong."); | |
156 | + } | |
157 | + | |
158 | + static void WriteLine(string message, [CallerMemberName] string caller = "") | |
159 | + { | |
160 | + Console.WriteLine($"{caller}: {message}"); | |
161 | + } | |
162 | + } | |
163 | +} |
@@ -0,0 +1,18 @@ | ||
1 | +using System; | |
2 | +using System.Threading.Tasks; | |
3 | + | |
4 | +namespace SL.SessionJwt.ConsoleClient | |
5 | +{ | |
6 | + static class Program | |
7 | + { | |
8 | + static async Task Main() | |
9 | + { | |
10 | + await new TestSayHello().Start(); | |
11 | + await new TestLogonLogout().Start(); | |
12 | + await new OutdatedTookie().Start(); | |
13 | + | |
14 | + Console.WriteLine("Press any key."); | |
15 | + Console.ReadKey(); | |
16 | + } | |
17 | + } | |
18 | +} |
@@ -0,0 +1,82 @@ | ||
1 | +using Grpc.Core; | |
2 | +using System; | |
3 | +using System.Runtime.CompilerServices; | |
4 | +using System.Threading.Tasks; | |
5 | + | |
6 | +namespace SL.SessionJwt.ConsoleClient | |
7 | +{ | |
8 | + public class TestLogonLogout | |
9 | + { | |
10 | + public async Task Start() | |
11 | + { | |
12 | + WriteLine($"=== {nameof(TestLogonLogout)} starts. ==="); | |
13 | + | |
14 | + await LogonByMissingUser(); | |
15 | + await LogonTwice(); | |
16 | + await LogoutTwice(); | |
17 | + | |
18 | + WriteLine($"=== {nameof(TestLogonLogout)} passed. ==={Environment.NewLine}"); | |
19 | + } | |
20 | + | |
21 | + private async Task LogonByMissingUser() | |
22 | + { | |
23 | + var user = new UserProxy("UserXYZ"); | |
24 | + try | |
25 | + { | |
26 | + await user.LogonAsync(); | |
27 | + } | |
28 | + catch (RpcException exception) | |
29 | + { | |
30 | + if (exception.StatusCode == StatusCode.InvalidArgument) | |
31 | + WriteLine("passed"); | |
32 | + else | |
33 | + WriteLine($"Exception {exception.Message}"); | |
34 | + } | |
35 | + catch (Exception exception) | |
36 | + { | |
37 | + WriteLine($"Exception {exception.Message}"); | |
38 | + } | |
39 | + } | |
40 | + | |
41 | + private async Task LogonTwice() | |
42 | + { | |
43 | + try | |
44 | + { | |
45 | + // second logon overrides old session | |
46 | + // and should not throw an error | |
47 | + var user = new UserProxy("User1"); | |
48 | + await user.LogonAsync(); | |
49 | + await user.LogonAsync(); | |
50 | + WriteLine("passed"); | |
51 | + } | |
52 | + catch (Exception exception) | |
53 | + { | |
54 | + WriteLine($"Exception {exception.Message}"); | |
55 | + } | |
56 | + } | |
57 | + | |
58 | + private async Task LogoutTwice() | |
59 | + { | |
60 | + try | |
61 | + { | |
62 | + // second logout should does just nothing | |
63 | + var user = new UserProxy("User1"); | |
64 | + await user.LogonAsync(); | |
65 | + var logout1Result = await user.LogoutAsync(); | |
66 | + var logout2Result = await user.LogoutAsync(); | |
67 | + if (!logout1Result || logout2Result) | |
68 | + throw new Exception($"logout1Result is{logout1Result} and logout2Result is {logout2Result}."); | |
69 | + WriteLine("passed"); | |
70 | + } | |
71 | + catch (Exception exception) | |
72 | + { | |
73 | + WriteLine($"Exception {exception.Message}"); | |
74 | + } | |
75 | + } | |
76 | + | |
77 | + static void WriteLine(string message, [CallerMemberName] string caller = "") | |
78 | + { | |
79 | + Console.WriteLine($"{caller}: {message}"); | |
80 | + } | |
81 | + } | |
82 | +} |
@@ -0,0 +1,43 @@ | ||
1 | +using SL.SessionJwt.Service; | |
2 | +using System; | |
3 | +using System.Runtime.CompilerServices; | |
4 | +using System.Threading.Tasks; | |
5 | + | |
6 | +namespace SL.SessionJwt.ConsoleClient | |
7 | +{ | |
8 | + public class TestSayHello | |
9 | + { | |
10 | + public async Task Start() | |
11 | + { | |
12 | + WriteLine($"=== {nameof(TestSayHello)} starts. ==="); | |
13 | + | |
14 | + await SayHelloAsync(); | |
15 | + | |
16 | + WriteLine($"=== {nameof(TestSayHello)} passed. ==={Environment.NewLine}"); | |
17 | + } | |
18 | + | |
19 | + private async Task SayHelloAsync() | |
20 | + { | |
21 | + try | |
22 | + { | |
23 | + var user = new UserProxy("User1"); | |
24 | + user.Invoker.WriteTokenToConsole = true; | |
25 | + await user.LogonAsync(); | |
26 | + await user.Client<Greeter.GreeterClient>().SayHelloAsync(new HelloRequest { Name = nameof(TestSayHello) }); | |
27 | + await user.Client<Greeter.GreeterClient>().SayHelloAsync(new HelloRequest { Name = nameof(TestSayHello) }); | |
28 | + await user.Client<Greeter.GreeterClient>().SayHelloAsync(new HelloRequest { Name = nameof(TestSayHello) }); | |
29 | + await user.LogoutAsync(); | |
30 | + WriteLine("passed"); | |
31 | + } | |
32 | + catch (Exception exception) | |
33 | + { | |
34 | + WriteLine($"{nameof(SayHelloAsync)} Exception {exception.Message}"); | |
35 | + } | |
36 | + } | |
37 | + | |
38 | + static void WriteLine(string message, [CallerMemberName] string caller = "") | |
39 | + { | |
40 | + Console.WriteLine($"{caller}: {message}"); | |
41 | + } | |
42 | + } | |
43 | +} |
@@ -0,0 +1,135 @@ | ||
1 | +using Grpc.Core; | |
2 | +using Grpc.Net.Client; | |
3 | +using System; | |
4 | +using System.Linq; | |
5 | +using System.Threading.Tasks; | |
6 | + | |
7 | +namespace SL.SessionJwt.ConsoleClient | |
8 | +{ | |
9 | + public class TokenCallInvoker : CallInvoker, IDisposable | |
10 | + { | |
11 | + public TokenCallInvoker(GrpcChannel channel) | |
12 | + { | |
13 | + Channel = channel; | |
14 | + Invoker = Channel.CreateCallInvoker(); | |
15 | + MetaData = new Metadata(); | |
16 | + } | |
17 | + | |
18 | + public GrpcChannel Channel { get; private set; } | |
19 | + | |
20 | + public CallInvoker Invoker { get; private set; } | |
21 | + | |
22 | + public Metadata MetaData { get; private set; } | |
23 | + | |
24 | + public string Token { get; set; } | |
25 | + | |
26 | + public bool WriteTokenToConsole { get; set; } // for testing | |
27 | + | |
28 | + public bool ValidateNewTokenIsDifferent { get; set; } = true; // for testing | |
29 | + | |
30 | + public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>( | |
31 | + Method<TRequest, TResponse> method, | |
32 | + string host, | |
33 | + CallOptions options, | |
34 | + TRequest request) | |
35 | + { | |
36 | + options = HandleCallOptions(options, method); | |
37 | + var call = Invoker.AsyncUnaryCall(method, host, options, request); | |
38 | + var result = new AsyncUnaryCall<TResponse>( | |
39 | + AsyncUnaryResponseAsync(call), | |
40 | + call.ResponseHeadersAsync, | |
41 | + call.GetStatus, | |
42 | + call.GetTrailers, | |
43 | + call.Dispose); | |
44 | + return result; | |
45 | + } | |
46 | + | |
47 | + | |
48 | + public override TResponse BlockingUnaryCall<TRequest, TResponse>(Method<TRequest, TResponse> method, string host, CallOptions options, TRequest request) | |
49 | + { | |
50 | + throw new NotImplementedException(); | |
51 | + } | |
52 | + | |
53 | + public override AsyncClientStreamingCall<TRequest, TResponse> AsyncClientStreamingCall<TRequest, TResponse>(Method<TRequest, TResponse> method, string host, CallOptions options) | |
54 | + { | |
55 | + throw new NotImplementedException(); | |
56 | + } | |
57 | + | |
58 | + public override AsyncDuplexStreamingCall<TRequest, TResponse> AsyncDuplexStreamingCall<TRequest, TResponse>(Method<TRequest, TResponse> method, string host, CallOptions options) | |
59 | + { | |
60 | + throw new NotImplementedException(); | |
61 | + } | |
62 | + | |
63 | + public override AsyncServerStreamingCall<TResponse> AsyncServerStreamingCall<TRequest, TResponse>(Method<TRequest, TResponse> method, string host, CallOptions options, TRequest request) | |
64 | + { | |
65 | + throw new NotImplementedException(); | |
66 | + } | |
67 | + | |
68 | + internal bool AddBearer(string token) | |
69 | + { | |
70 | + if (ValidateNewTokenIsDifferent && | |
71 | + token.Equals(Token, StringComparison.CurrentCultureIgnoreCase)) | |
72 | + throw new ArgumentOutOfRangeException(nameof(token)); | |
73 | + | |
74 | + var result = RemoveBearer(); | |
75 | + MetaData.Add(Constants.TokenName, Constants.BearerPrefix + token); | |
76 | + Token = token; | |
77 | + return result; | |
78 | + } | |
79 | + | |
80 | + internal bool RemoveBearer() | |
81 | + { | |
82 | + var result = default(bool); | |
83 | + var item = MetaData.FirstOrDefault(e => e.Key.Equals(Constants.TokenName, StringComparison.InvariantCultureIgnoreCase)); | |
84 | + if (null != item) | |
85 | + result = MetaData.Remove(item); | |
86 | + Token = String.Empty; | |
87 | + return result; | |
88 | + } | |
89 | + | |
90 | + private CallOptions HandleCallOptions<TRequest, TResponse>(CallOptions options, Method<TRequest, TResponse> method) | |
91 | + { | |
92 | + if (null == options.Headers) | |
93 | + options = new CallOptions(new Metadata(), | |
94 | + options.Deadline, | |
95 | + options.CancellationToken, | |
96 | + options.WriteOptions, | |
97 | + options.PropagationToken); | |
98 | + foreach (var item in MetaData) | |
99 | + options.Headers.Add(item.Key, item.Value); | |
100 | + | |
101 | + if (WriteTokenToConsole && MetaData.Count > 0) | |
102 | + { | |
103 | + var token = MetaData[0].Value.Substring(Constants.BearerPrefix.Length, 20) + " " + MetaData[0].Value.Substring(MetaData[0].Value.Length - 20); | |
104 | + Console.WriteLine($"{method.ServiceName} call with {token}"); | |
105 | + } | |
106 | + return options; | |
107 | + } | |
108 | + | |
109 | + private async Task<TResponse> AsyncUnaryResponseAsync<TResponse>(AsyncUnaryCall<TResponse> underlying) where TResponse : class | |
110 | + { | |
111 | + var result = await underlying.ResponseAsync; | |
112 | + var trailers = underlying.GetTrailers(); | |
113 | + var test = underlying.ResponseHeadersAsync; | |
114 | + RefreshToken(trailers); | |
115 | + return result; | |
116 | + } | |
117 | + | |
118 | + private void RefreshToken(Metadata trailers) | |
119 | + { | |
120 | + var tokenPair = trailers.FirstOrDefault(e => Constants.TokenName.Equals(e.Key, StringComparison.InvariantCultureIgnoreCase)); | |
121 | + if (null != tokenPair) | |
122 | + { | |
123 | + var removed = AddBearer(tokenPair.Value); | |
124 | + if(WriteTokenToConsole) | |
125 | + Console.WriteLine($"Token refreshed." + (true == removed ? "(Old Token removed.)" : "")); | |
126 | + } | |
127 | + } | |
128 | + | |
129 | + public void Dispose() | |
130 | + { | |
131 | + Channel?.Dispose(); | |
132 | + Channel = null; | |
133 | + } | |
134 | + } | |
135 | +} |
@@ -0,0 +1,76 @@ | ||
1 | +using Grpc.Core; | |
2 | +using Grpc.Net.Client; | |
3 | +using System; | |
4 | +using System.Linq; | |
5 | +using System.Collections.Generic; | |
6 | +using System.Threading.Tasks; | |
7 | +using static SL.SessionJwt.Service.Authentication; | |
8 | +using SL.SessionJwt.Service; | |
9 | + | |
10 | +namespace SL.SessionJwt.ConsoleClient | |
11 | +{ | |
12 | + public class UserProxy : IDisposable | |
13 | + { | |
14 | + public UserProxy(string userName) | |
15 | + { | |
16 | + UserName = userName; | |
17 | + Invoker = new TokenCallInvoker(GrpcChannel.ForAddress(Constants.RemoteEndPoint)); | |
18 | + } | |
19 | + | |
20 | + public string UserName { get; private set; } | |
21 | + | |
22 | + public TokenCallInvoker Invoker { get; private set; } | |
23 | + | |
24 | + internal List<ClientBase> Clients { get; private set; } = new List<ClientBase>(); | |
25 | + | |
26 | + public virtual T Client<T>() where T : ClientBase | |
27 | + { | |
28 | + var client = Clients.FirstOrDefault(e => e.GetType() == typeof(T)) as T; | |
29 | + if (null == client) | |
30 | + { | |
31 | + client = (T)Activator.CreateInstance(typeof(T), Invoker); | |
32 | + Clients.Add(client); | |
33 | + } | |
34 | + return client; | |
35 | + } | |
36 | + | |
37 | + public virtual async Task RenewAsync() | |
38 | + { | |
39 | + using (var channel = GrpcChannel.ForAddress(Constants.RemoteEndPoint)) | |
40 | + { | |
41 | + var client = new AuthenticationClient(channel); | |
42 | + var response = await client.RenewAsync(new RenewRequest { Token = Invoker.Token }); | |
43 | + Invoker.AddBearer(response.Token); | |
44 | + } | |
45 | + } | |
46 | + | |
47 | + public virtual async Task LogonAsync() | |
48 | + { | |
49 | + using (var channel = GrpcChannel.ForAddress(Constants.RemoteEndPoint)) | |
50 | + { | |
51 | + var client = new AuthenticationClient(channel); | |
52 | + var response = await client.LogonAsync(new LogonRequest { Name = UserName }); | |
53 | + Invoker.AddBearer(response.Token); | |
54 | + } | |
55 | + } | |
56 | + | |
57 | + public virtual async Task<bool> LogoutAsync(bool keepToken = false) // keepToken for greybox testing purpose | |
58 | + { | |
59 | + using (var channel = GrpcChannel.ForAddress(Constants.RemoteEndPoint)) | |
60 | + { | |
61 | + var client = new AuthenticationClient(channel); | |
62 | + var result = await client.LogoutAsync(new Google.Protobuf.WellKnownTypes.Empty(), Invoker.MetaData); | |
63 | + if(!keepToken) | |
64 | + Invoker.RemoveBearer(); | |
65 | + return await Task.FromResult(result.HasLoggedOut); | |
66 | + } | |
67 | + } | |
68 | + | |
69 | + public virtual void Dispose() | |
70 | + { | |
71 | + Clients.Clear(); | |
72 | + Invoker?.Dispose(); | |
73 | + Invoker = null; | |
74 | + } | |
75 | + } | |
76 | +} |
@@ -0,0 +1,33 @@ | ||
1 | +using System; | |
2 | +using System.Collections.Generic; | |
3 | +using System.IdentityModel.Tokens.Jwt; | |
4 | +using System.Linq; | |
5 | +using System.Security.Claims; | |
6 | + | |
7 | +namespace SL.SessionJwt.Service | |
8 | +{ | |
9 | + public class JwtClaimReader | |
10 | + { | |
11 | + public JwtClaimReader(IEnumerable<Claim> claims) | |
12 | + { | |
13 | + Claims = claims ?? throw new ArgumentNullException(nameof(claims)); | |
14 | + } | |
15 | + | |
16 | + public IEnumerable<Claim> Claims { get; private set; } | |
17 | + | |
18 | + public virtual string SessionId() | |
19 | + { | |
20 | + return Claims.First(e => e.Type == JwtRegisteredClaimNames.Jti).Value.ToString(); | |
21 | + } | |
22 | + | |
23 | + public virtual string Userid() | |
24 | + { | |
25 | + return Claims.First(e => e.Type == JwtRegisteredClaimNames.NameId).Value.ToString(); | |
26 | + } | |
27 | + | |
28 | + public virtual string Aud() | |
29 | + { | |
30 | + return Claims.First(e => e.Type == JwtRegisteredClaimNames.Aud).Value.ToString(); | |
31 | + } | |
32 | + } | |
33 | +} |
@@ -0,0 +1,49 @@ | ||
1 | +using SL.SessionJwt.Service.Model; | |
2 | +using System; | |
3 | +using System.Collections.Generic; | |
4 | +using System.IdentityModel.Tokens.Jwt; | |
5 | +using System.Linq; | |
6 | +using System.Security.Claims; | |
7 | +using System.Threading.Tasks; | |
8 | + | |
9 | +namespace SL.SessionJwt.Service | |
10 | +{ | |
11 | + public class JwtClaimWriter | |
12 | + { | |
13 | + public JwtClaimWriter(string userAgent, string userId, string userName, string sessionId, IEnumerable<Role> roles) | |
14 | + { | |
15 | + UserAgent = userAgent ?? throw new ArgumentNullException(nameof(userAgent)); | |
16 | + UserId = userId ?? throw new ArgumentNullException(nameof(userId)); | |
17 | + UserName = userName ?? throw new ArgumentNullException(nameof(userName)); | |
18 | + SessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); | |
19 | + Roles = roles ?? throw new ArgumentNullException(nameof(roles)); | |
20 | + } | |
21 | + | |
22 | + public string UserAgent { get; private set; } | |
23 | + | |
24 | + public string UserId { get; private set; } | |
25 | + | |
26 | + public string UserName { get; private set; } | |
27 | + | |
28 | + public string SessionId { get; private set; } | |
29 | + | |
30 | + public IEnumerable<Role> Roles { get; private set; } | |
31 | + | |
32 | + public virtual async Task<List<Claim>> WriteClaimsForSession() | |
33 | + { | |
34 | + var claims = await Task.Run<List<Claim>>(() => | |
35 | + { | |
36 | + var claims = new List<Claim>() | |
37 | + { | |
38 | + new Claim(JwtRegisteredClaimNames.Aud, UserAgent), | |
39 | + new Claim(ClaimTypes.NameIdentifier, UserId), | |
40 | + new Claim(ClaimTypes.Name, UserName), | |
41 | + new Claim(JwtRegisteredClaimNames.Jti, SessionId) | |
42 | + }; | |
43 | + Roles.ToList().ForEach(e => claims.Add(new Claim(ClaimTypes.Role, e.ToString()))); | |
44 | + return claims; | |
45 | + }); | |
46 | + return claims; | |
47 | + } | |
48 | + } | |
49 | +} |
@@ -0,0 +1,43 @@ | ||
1 | +using Grpc.Core; | |
2 | +using System; | |
3 | +using System.Linq; | |
4 | +using System.Threading.Tasks; | |
5 | + | |
6 | +namespace SL.SessionJwt.Service | |
7 | +{ | |
8 | + public class JwtMetadataTokenReader | |
9 | + { | |
10 | + public JwtMetadataTokenReader(Metadata meta) | |
11 | + { | |
12 | + Meta = meta ?? throw new ArgumentNullException(nameof(meta)); | |
13 | + } | |
14 | + | |
15 | + public Metadata Meta { get; private set; } | |
16 | + | |
17 | + public virtual async Task<string> Read() | |
18 | + { | |
19 | + return await Task.Run(() => | |
20 | + { | |
21 | + var result = RecieveBearerToken(Meta); | |
22 | + result = ValidateBearerToken(result); | |
23 | + return result; | |
24 | + }); | |
25 | + } | |
26 | + | |
27 | + internal virtual string RecieveBearerToken(Metadata meta) | |
28 | + { | |
29 | + var result = default(string); | |
30 | + var pair = meta.FirstOrDefault(e => Constants.AuthorizationHeader.Equals(e.Key, StringComparison.InvariantCultureIgnoreCase)); | |
31 | + if (null != pair) | |
32 | + result = pair.Value; | |
33 | + return result; | |
34 | + } | |
35 | + | |
36 | + internal virtual string ValidateBearerToken(string token) | |
37 | + { | |
38 | + return true == token?.StartsWith(Constants.BearerPrefix, StringComparison.InvariantCultureIgnoreCase) | |
39 | + ? token.Substring(Constants.BearerPrefix.Length) | |
40 | + : token; | |
41 | + } | |
42 | + } | |
43 | +} | |
\ No newline at end of file |
@@ -0,0 +1,30 @@ | ||
1 | +using Grpc.Core; | |
2 | +using System; | |
3 | +using System.Collections.Generic; | |
4 | +using System.Linq; | |
5 | +using System.Threading.Tasks; | |
6 | + | |
7 | +namespace SL.SessionJwt.Service | |
8 | +{ | |
9 | + public class JwtMetadataUserAgentReader | |
10 | + { | |
11 | + public JwtMetadataUserAgentReader(Metadata meta) | |
12 | + { | |
13 | + Meta = meta ?? throw new ArgumentNullException(nameof(meta)); | |
14 | + } | |
15 | + | |
16 | + public Metadata Meta { get; private set; } | |
17 | + | |
18 | + public virtual async Task<string> Read() | |
19 | + { | |
20 | + return await Task.Run(() => | |
21 | + { | |
22 | + var result = default(string); | |
23 | + var pair = Meta.FirstOrDefault(e => Constants.UserAgentHeader.Equals(e.Key, StringComparison.InvariantCultureIgnoreCase)); | |
24 | + if (null != pair) | |
25 | + result = pair.Value; | |
26 | + return result; | |
27 | + }); | |
28 | + } | |
29 | + } | |
30 | +} |
@@ -0,0 +1,32 @@ | ||
1 | +using System; | |
2 | +using System.IdentityModel.Tokens.Jwt; | |
3 | +using System.Threading.Tasks; | |
4 | + | |
5 | +namespace SL.SessionJwt.Service | |
6 | +{ | |
7 | + public class JwtSecurityTokenDecoder | |
8 | + { | |
9 | + public JwtSecurityTokenDecoder(string token) | |
10 | + { | |
11 | + if(String.IsNullOrWhiteSpace(token)) | |
12 | + throw new ArgumentNullException(nameof(token)); | |
13 | + Token = token; | |
14 | + } | |
15 | + | |
16 | + public string Token { get; private set; } | |
17 | + | |
18 | + public virtual async Task<JwtSecurityToken> Decode() | |
19 | + { | |
20 | + return await Task.Run(() => | |
21 | + { | |
22 | + var result = default(JwtSecurityToken); | |
23 | + if (!String.IsNullOrWhiteSpace(Token)) | |
24 | + { | |
25 | + var jwtHandler = new JwtSecurityTokenHandler(); | |
26 | + result = jwtHandler.ReadJwtToken(Token); | |
27 | + } | |
28 | + return result; | |
29 | + }); | |
30 | + } | |
31 | + } | |
32 | +} |
@@ -0,0 +1,12 @@ | ||
1 | +using System; | |
2 | + | |
3 | +namespace SL.SessionJwt.Service | |
4 | +{ | |
5 | + public enum JwtSessionValidationResult | |
6 | + { | |
7 | + Fine = 0, | |
8 | + NoSession = 1, | |
9 | + RolesChanged = 2, | |
10 | + Error = 3 | |
11 | + } | |
12 | +} |
@@ -0,0 +1,59 @@ | ||
1 | +using Microsoft.Extensions.Logging; | |
2 | +using System; | |
3 | +using System.IdentityModel.Tokens.Jwt; | |
4 | +using System.Linq; | |
5 | +using System.Threading.Tasks; | |
6 | + | |
7 | +namespace SL.SessionJwt.Service | |
8 | +{ | |
9 | + public class JwtSessionValidator | |
10 | + { | |
11 | + public JwtSessionValidator(ILogger<JwtSessionValidator> logger, FakeUserRepository users) | |
12 | + { | |
13 | + Logger = logger; | |
14 | + Users = users ?? throw new ArgumentNullException(nameof(users)); | |
15 | + } | |
16 | + | |
17 | + public ILogger<JwtSessionValidator> Logger { get; private set; } | |
18 | + | |
19 | + public FakeUserRepository Users { get; private set; } | |
20 | + | |
21 | + public async Task<JwtSessionValidationResult> IsValidSession(JwtSecurityToken token) | |
22 | + { | |
23 | + try | |
24 | + { | |
25 | + var claims = new JwtClaimReader(token.Claims); | |
26 | + var sessionId = claims.SessionId(); | |
27 | + var userId = claims.Userid(); | |
28 | + | |
29 | + var user = await Task.Run(() => | |
30 | + { | |
31 | + return Users.FirstOrDefault(e => e.Id.Equals(userId)); | |
32 | + }); | |
33 | + if(null == user) | |
34 | + return JwtSessionValidationResult.Error; | |
35 | + | |
36 | + if (user.SessionId != sessionId) | |
37 | + return JwtSessionValidationResult.NoSession; | |
38 | + | |
39 | + var roleClaims = await Task.Run(() => | |
40 | + { | |
41 | + return token.Claims. | |
42 | + Where(e => e.Type == nameof(Model.Role).ToLower()). | |
43 | + Select(e => e.Value.ToLower()); | |
44 | + }); | |
45 | + | |
46 | + if (roleClaims.Count() != user.Roles.Length | |
47 | + && !user.Roles.Select(e => e.ToString().ToLower()).All(e => roleClaims.Contains(e))) | |
48 | + return JwtSessionValidationResult.RolesChanged; | |
49 | + | |
50 | + return JwtSessionValidationResult.Fine; | |
51 | + } | |
52 | + catch (Exception exception) | |
53 | + { | |
54 | + Logger.LogError(exception, nameof(IsValidSession)); | |
55 | + return JwtSessionValidationResult.Error; | |
56 | + } | |
57 | + } | |
58 | + } | |
59 | +} |
@@ -0,0 +1,120 @@ | ||
1 | +using Microsoft.Extensions.Logging; | |
2 | +using Microsoft.IdentityModel.Tokens; | |
3 | +using System; | |
4 | +using System.Collections.Generic; | |
5 | +using System.IdentityModel.Tokens.Jwt; | |
6 | +using System.Linq; | |
7 | +using System.Security.Claims; | |
8 | +using System.Threading.Tasks; | |
9 | + | |
10 | +namespace SL.SessionJwt.Service | |
11 | +{ | |
12 | + public class JwtSigningTokenHandler : JwtSecurityTokenHandler | |
13 | + { | |
14 | + public JwtSigningTokenHandler(string token, TokenValidationParameters validationParameters) | |
15 | + { | |
16 | + Token = token ?? throw new ArgumentNullException(nameof(token)); | |
17 | + ValidationParameters = validationParameters ?? throw new ArgumentNullException(nameof(validationParameters)); | |
18 | + } | |
19 | + | |
20 | + public string Token { get; private set; } | |
21 | + | |
22 | + public TokenValidationParameters ValidationParameters { get; private set; } | |
23 | + | |
24 | + public virtual async Task<JwtSecurityToken> ValidateWitoutLifetime() | |
25 | + { | |
26 | + return await Task.Run(() => | |
27 | + { | |
28 | + var result = default(JwtSecurityToken); | |
29 | + var jwtToken = SafeValidateSignature(Token, ValidationParameters); | |
30 | + if (null != jwtToken) | |
31 | + { | |
32 | + if (SafeValidateTokenPayloadWithoutLifetime(jwtToken, ValidationParameters) | |
33 | + && IsValidLifetimeOrExpiresWithinRenewPeriod(jwtToken)) | |
34 | + result = jwtToken; | |
35 | + } | |
36 | + return result; | |
37 | + }); | |
38 | + } | |
39 | + | |
40 | + internal JwtSecurityToken SafeValidateSignature(string token, TokenValidationParameters validationParameters) | |
41 | + { | |
42 | + var result = default(JwtSecurityToken); | |
43 | + try | |
44 | + { | |
45 | + result = ValidateSignature(token, validationParameters); | |
46 | + } | |
47 | + catch | |
48 | + { | |
49 | + ; | |
50 | + } | |
51 | + return result; | |
52 | + } | |
53 | + | |
54 | + internal virtual bool SafeValidateTokenPayloadWithoutLifetime(JwtSecurityToken jwtToken, TokenValidationParameters validationParameters) | |
55 | + { | |
56 | + var result = default(bool); | |
57 | + try | |
58 | + { | |
59 | + var expires = (!jwtToken.Payload.Exp.HasValue) ? null : new DateTime?(jwtToken.ValidTo); | |
60 | + ValidateAudience(jwtToken.Audiences, jwtToken, validationParameters); | |
61 | + string issuer = ValidateIssuer(jwtToken.Issuer, jwtToken, validationParameters); | |
62 | + ValidateTokenReplay(expires, jwtToken.RawData, validationParameters); | |
63 | + if (validationParameters.ValidateActor && !String.IsNullOrWhiteSpace(jwtToken.Actor)) | |
64 | + ValidateToken(jwtToken.Actor, validationParameters.ActorValidationParameters ?? validationParameters, out SecurityToken _); | |
65 | + ValidateIssuerSecurityKey(jwtToken.SigningKey, jwtToken, validationParameters); | |
66 | + result = true; | |
67 | + } | |
68 | + catch | |
69 | + { | |
70 | + ; | |
71 | + } | |
72 | + return result; | |
73 | + } | |
74 | + | |
75 | + internal virtual bool ValidateLifetime(DateTime? notBefore, DateTime? expires, | |
76 | + SecurityToken securityToken, | |
77 | + TokenValidationParameters validationParameters) | |
78 | + { | |
79 | + if (validationParameters == null) | |
80 | + return false; | |
81 | + if (!validationParameters.ValidateLifetime) | |
82 | + return false; | |
83 | + | |
84 | + if (validationParameters.LifetimeValidator != null) | |
85 | + { | |
86 | + if (validationParameters.LifetimeValidator(notBefore, expires, securityToken, validationParameters)) | |
87 | + return false; | |
88 | + | |
89 | + } | |
90 | + if (!expires.HasValue && validationParameters.RequireExpirationTime) | |
91 | + return false; | |
92 | + if (notBefore.HasValue && expires.HasValue && notBefore.Value > expires.Value) | |
93 | + return false; | |
94 | + | |
95 | + DateTime utcNow = DateTime.UtcNow; | |
96 | + if (notBefore.HasValue && notBefore.Value > DateTimeUtil.Add(utcNow, validationParameters.ClockSkew)) | |
97 | + return false; | |
98 | + | |
99 | + if (expires.HasValue && expires.Value < DateTimeUtil.Add(utcNow, validationParameters.ClockSkew.Negate())) | |
100 | + return false; | |
101 | + | |
102 | + return true; | |
103 | + } | |
104 | + internal virtual bool IsValidLifetimeOrExpiresWithinRenewPeriod(JwtSecurityToken jwtToken) | |
105 | + { | |
106 | + var result = default(bool); | |
107 | + | |
108 | + var notBefore = (!jwtToken.Payload.Nbf.HasValue) ? null : new DateTime?(jwtToken.ValidFrom); | |
109 | + var expires = (!jwtToken.Payload.Exp.HasValue) ? null : new DateTime?(jwtToken.ValidTo); | |
110 | + | |
111 | + if (!ValidateLifetime(notBefore, expires, jwtToken, ValidationParameters) | |
112 | + && expires.HasValue && notBefore.HasValue) | |
113 | + result = ((DateTime.UtcNow - expires.Value).TotalSeconds <= Constants.RenewPeriodSeconds); | |
114 | + else | |
115 | + result = true; | |
116 | + | |
117 | + return result; | |
118 | + } | |
119 | + } | |
120 | +} |
@@ -0,0 +1,59 @@ | ||
1 | +using Microsoft.IdentityModel.Tokens; | |
2 | +using System; | |
3 | +using System.Collections.Generic; | |
4 | +using System.IdentityModel.Tokens.Jwt; | |
5 | +using System.Linq; | |
6 | +using System.Security.Claims; | |
7 | +using System.Threading.Tasks; | |
8 | + | |
9 | +namespace SL.SessionJwt.Service | |
10 | +{ | |
11 | + public class JwtTokenBuilder | |
12 | + { | |
13 | + public JwtTokenBuilder(IEnumerable<Claim> claims) | |
14 | + { | |
15 | + Claims = claims ?? throw new ArgumentNullException(nameof(claims)); | |
16 | + AddByteMagicToClaims(); | |
17 | + } | |
18 | + | |
19 | + public IEnumerable<Claim> Claims { get; private set; } | |
20 | + | |
21 | + public virtual async Task<string> BuildTokenString() | |
22 | + { | |
23 | + var descriptor = await CreateTokenDescriptor(); | |
24 | + return await Task.Run(() => | |
25 | + { | |
26 | + var tokenHandler = new JwtSecurityTokenHandler(); | |
27 | + var token = tokenHandler.CreateToken(descriptor); | |
28 | + return tokenHandler.WriteToken(token); | |
29 | + }); | |
30 | + } | |
31 | + | |
32 | + internal virtual async Task<SecurityTokenDescriptor> CreateTokenDescriptor() | |
33 | + { | |
34 | + return await Task.Run(() => | |
35 | + { | |
36 | + var tokenDescriptor = new SecurityTokenDescriptor() | |
37 | + { | |
38 | + Subject = new ClaimsIdentity(Claims), | |
39 | + NotBefore = DateTime.UtcNow, | |
40 | + IssuedAt = DateTime.UtcNow, | |
41 | + Issuer = typeof(JwtTokenBuilder).Assembly.FullName, | |
42 | + Expires = DateTime.UtcNow.AddSeconds(Constants.SessionTimeoutSeconds), | |
43 | + SigningCredentials = new SigningCredentials( | |
44 | + new SymmetricSecurityKey(Constants.SigningKey), | |
45 | + SecurityAlgorithms.HmacSha256) | |
46 | + }; | |
47 | + return tokenDescriptor; | |
48 | + }); | |
49 | + } | |
50 | + | |
51 | + // see Constants.cs why this is useful | |
52 | + private void AddByteMagicToClaims() | |
53 | + { | |
54 | + var list = Claims.ToList(); | |
55 | + list.Add(new Claim("Magic", new Guid().ToString().Replace("-", ""))); | |
56 | + Claims = list; | |
57 | + } | |
58 | + } | |
59 | +} |
@@ -0,0 +1,44 @@ | ||
1 | +using System; | |
2 | +using System.Collections.Generic; | |
3 | +using System.IdentityModel.Tokens.Jwt; | |
4 | +using System.Linq; | |
5 | +using System.Threading.Tasks; | |
6 | +using Grpc.Core; | |
7 | +using Microsoft.AspNetCore.Authentication.JwtBearer; | |
8 | +using Microsoft.AspNetCore.Http; | |
9 | + | |
10 | +namespace SL.SessionJwt.Service.JwtExtensions | |
11 | +{ | |
12 | + public class JwtTokenEvents : JwtBearerEvents | |
13 | + { | |
14 | + public JwtTokenEvents(FakeUserRepository users, JwtSessionValidator validator) | |
15 | + { | |
16 | + Users = users ?? throw new ArgumentNullException(nameof(users)); | |
17 | + Validator = validator ?? throw new ArgumentNullException(nameof(validator)); | |
18 | + } | |
19 | + | |
20 | + public JwtSessionValidator Validator { get; private set; } | |
21 | + | |
22 | + public FakeUserRepository Users { get; private set; } | |
23 | + | |
24 | + public override async Task TokenValidated(TokenValidatedContext context) | |
25 | + { | |
26 | + var jwtToken = context.SecurityToken as JwtSecurityToken; | |
27 | + if (null != jwtToken) | |
28 | + { | |
29 | + var result = await Validator?.IsValidSession(jwtToken); | |
30 | + if (result == JwtSessionValidationResult.NoSession) | |
31 | + throw new RpcException(new Status(StatusCode.Aborted, Constants.SessionValidateLoggedOutMessage)); | |
32 | + else if (result == JwtSessionValidationResult.RolesChanged) | |
33 | + throw new RpcException(new Status(StatusCode.Aborted, Constants.SessionValidateRolesChangedMessage)); | |
34 | + else if (result == JwtSessionValidationResult.Error) | |
35 | + throw new RpcException(new Status(StatusCode.Internal, Constants.SessionValidateErrorMessage)); | |
36 | + | |
37 | + var tokenString = await new JwtTokenBuilder( | |
38 | + jwtToken.Claims.Where(e => !ClaimTlas.TimeTlas.Contains(e.Type))).BuildTokenString(); | |
39 | + | |
40 | + context.Response.AppendTrailer(Constants.AuthorizationHeader, tokenString); | |
41 | + } | |
42 | + } | |
43 | + } | |
44 | +} | |
\ No newline at end of file |
@@ -0,0 +1,14 @@ | ||
1 | +using System; | |
2 | +using System.Collections.Generic; | |
3 | +using System.Linq; | |
4 | +using System.Threading.Tasks; | |
5 | + | |
6 | +namespace SL.SessionJwt.Service.Model | |
7 | +{ | |
8 | + public enum Role | |
9 | + { | |
10 | + Customer = 0, | |
11 | + Employee = 1, | |
12 | + Administrator = 3 | |
13 | + } | |
14 | +} |
@@ -0,0 +1,25 @@ | ||
1 | +using System; | |
2 | +using System.Collections.Generic; | |
3 | +using System.Linq; | |
4 | +using System.Threading.Tasks; | |
5 | + | |
6 | +namespace SL.SessionJwt.Service.Model | |
7 | +{ | |
8 | + public class User | |
9 | + { | |
10 | + public User(Guid id, string name, params Role[] roles) | |
11 | + { | |
12 | + Id = id.ToString().ToLower(); | |
13 | + Name = name ?? throw new ArgumentNullException(nameof(name)); | |
14 | + Roles = roles; | |
15 | + } | |
16 | + | |
17 | + public string Id { get; private set; } | |
18 | + | |
19 | + public string Name { get; private set; } | |
20 | + | |
21 | + public Role[] Roles { get; private set; } | |
22 | + | |
23 | + public string SessionId { get; internal set; } | |
24 | + } | |
25 | +} |
@@ -0,0 +1,12 @@ | ||
1 | +{ | |
2 | + "profiles": { | |
3 | + "SL.JwtLifetime.Service": { | |
4 | + "commandName": "Project", | |
5 | + "launchBrowser": false, | |
6 | + "applicationUrl": "https://localhost:5001", | |
7 | + "environmentVariables": { | |
8 | + "ASPNETCORE_ENVIRONMENT": "Development" | |
9 | + } | |
10 | + } | |
11 | + } | |
12 | +} |
@@ -0,0 +1,80 @@ | ||
1 | +using System; | |
2 | +using System.Linq; | |
3 | +using System.Threading.Tasks; | |
4 | +using Google.Protobuf.WellKnownTypes; | |
5 | +using Grpc.Core; | |
6 | + | |
7 | +namespace SL.SessionJwt.Service.Services | |
8 | +{ | |
9 | + public class AuthenticationService : Authentication.AuthenticationBase | |
10 | + { | |
11 | + public AuthenticationService(FakeUserRepository users) | |
12 | + { | |
13 | + Users = users; | |
14 | + } | |
15 | + | |
16 | + public FakeUserRepository Users { get; private set; } | |
17 | + | |
18 | + public override async Task<LogonReply> Logon(LogonRequest request, ServerCallContext context) | |
19 | + { | |
20 | + return await Task.Run(async () => | |
21 | + { | |
22 | + var user = await Users.FindUserByName<RpcException>(request.Name, new Status(StatusCode.InvalidArgument, String.Empty)); | |
23 | + var sessionId = Guid.NewGuid().ToString(); | |
24 | + var userAgent = await new JwtMetadataUserAgentReader(context.RequestHeaders).Read(); | |
25 | + var claims = await new JwtClaimWriter(userAgent, user.Id, user.Name, sessionId, user.Roles). | |
26 | + WriteClaimsForSession(); | |
27 | + var tokenString = await new JwtTokenBuilder(claims).BuildTokenString(); | |
28 | + user.SessionId = sessionId; | |
29 | + return new LogonReply { Token = tokenString }; | |
30 | + }); | |
31 | + } | |
32 | + | |
33 | + public override async Task<LogoutReply> Logout(Empty request, ServerCallContext context) | |
34 | + { | |
35 | + return await Task.Run(async() => | |
36 | + { | |
37 | + var result = new LogoutReply(); | |
38 | + var bearerToken = await new JwtMetadataTokenReader(context.RequestHeaders).Read(); | |
39 | + var jwtToken = null != bearerToken ? await new JwtSecurityTokenDecoder(bearerToken).Decode() : null; | |
40 | + if (null != jwtToken) | |
41 | + { | |
42 | + var claims = new JwtClaimReader(jwtToken.Claims); | |
43 | + var sessionId = claims.SessionId(); | |
44 | + var user = Users.FirstOrDefault(e => e.SessionId.ToString() == sessionId); | |
45 | + if (null != user) | |
46 | + { | |
47 | + user.SessionId = null; | |
48 | + result.HasLoggedOut = true; | |
49 | + } | |
50 | + } | |
51 | + return result; | |
52 | + }); | |
53 | + } | |
54 | + | |
55 | + public override async Task<RenewReply> Renew(RenewRequest request, ServerCallContext context) | |
56 | + { | |
57 | + return await Task.Run(async () => | |
58 | + { | |
59 | + var handler = new JwtSigningTokenHandler(request.Token, Constants.TokenValidation); | |
60 | + var token = await handler.ValidateWitoutLifetime(); | |
61 | + if (null != token) | |
62 | + { | |
63 | + var claimReader = new JwtClaimReader(token.Claims); | |
64 | + var sessionId = claimReader.SessionId(); | |
65 | + var aud = claimReader.Aud(); | |
66 | + var user = Users.FirstOrDefault(e => e.SessionId == sessionId); | |
67 | + if (null != user) | |
68 | + { | |
69 | + var claims = await new JwtClaimWriter(aud, user.Id, user.Name, sessionId, user.Roles). | |
70 | + WriteClaimsForSession(); | |
71 | + var tokenString = await new JwtTokenBuilder(claims).BuildTokenString(); | |
72 | + return new RenewReply { Token = tokenString }; | |
73 | + } | |
74 | + } | |
75 | + | |
76 | + throw new RpcException(new Status(StatusCode.Unauthenticated, Constants.RenewErrorMessage)); | |
77 | + }); | |
78 | + } | |
79 | + } | |
80 | +} |
@@ -0,0 +1,23 @@ | ||
1 | +using System; | |
2 | +using System.Collections.Generic; | |
3 | +using System.Linq; | |
4 | +using System.Threading.Tasks; | |
5 | +using Grpc.Core; | |
6 | +using Microsoft.AspNetCore.Authorization; | |
7 | +using Microsoft.Extensions.Logging; | |
8 | +using SL.SessionJwt.Service.Model; | |
9 | + | |
10 | +namespace SL.SessionJwt.Service.Services | |
11 | +{ | |
12 | + public class GreeterService : Greeter.GreeterBase | |
13 | + { | |
14 | + [Authorize(Roles = "Administrator,Employee,Customer")] | |
15 | + public override Task<HelloReply> SayHello(HelloRequest request, ServerCallContext context) | |
16 | + { | |
17 | + return Task.FromResult(new HelloReply | |
18 | + { | |
19 | + Message = "Hello " + request.Name | |
20 | + }); | |
21 | + } | |
22 | + } | |
23 | +} |
@@ -0,0 +1,61 @@ | ||
1 | +using Microsoft.IdentityModel.Tokens; | |
2 | +using System; | |
3 | +using System.Text; | |
4 | + | |
5 | +namespace SL.SessionJwt.Service | |
6 | +{ | |
7 | + public static class Constants | |
8 | + { | |
9 | + public static readonly int SessionTimeoutSeconds = 60; | |
10 | + | |
11 | + public static readonly int RenewPeriodSeconds = 60; | |
12 | + | |
13 | + public static readonly byte[] SigningKey = Encoding.UTF8.GetBytes("the password is swordfish"); // dont store here in real scenario | |
14 | + | |
15 | + public static readonly TokenValidationParameters TokenValidation = new TokenValidationParameters | |
16 | + { | |
17 | + RequireSignedTokens = true, | |
18 | + ValidateIssuerSigningKey = true, | |
19 | + IssuerSigningKey = new SymmetricSecurityKey(SigningKey), | |
20 | + ValidateIssuer = false, | |
21 | + ValidateAudience = false, | |
22 | + ValidateLifetime = true, | |
23 | + ValidateActor = true, | |
24 | + RequireExpirationTime = true, | |
25 | + RequireAudience = true, | |
26 | + ClockSkew = TimeSpan.FromSeconds(1) | |
27 | + }; | |
28 | + | |
29 | + public static readonly string SessionValidateLoggedOutMessage = "Specified session not found."; | |
30 | + | |
31 | + public static readonly string SessionValidateRolesChangedMessage = "Specified roles has been changed during session. Please reauthenticate."; | |
32 | + | |
33 | + public static readonly string SessionValidateErrorMessage = "An error has occured. This indicates user is already deleted."; | |
34 | + | |
35 | + public static readonly string RenewErrorMessage = $"Session is may over or user is already deleted. Moreover you can renew an outdated tokie only within {RenewPeriodSeconds} seconds."; | |
36 | + | |
37 | + public static readonly string BearerPrefix = "Bearer "; | |
38 | + | |
39 | + public static readonly string AuthorizationHeader = "Authorization"; | |
40 | + | |
41 | + public static readonly string UserAgentHeader = "user-agent"; | |
42 | + | |
43 | + /* | |
44 | + This is a hotfix for testing purpose to make sure a regenerated token from JwtTokenEvents | |
45 | + is different from each other because IssuedAt/Expires claim Utc time accuracy is only seconds. | |
46 | + So if we ran the tests localy very fast, we got the same token which is an issue for testing. | |
47 | + */ | |
48 | + public static readonly string MagicClaim = "Magic "; | |
49 | + } | |
50 | + | |
51 | + public static class ClaimTlas | |
52 | + { | |
53 | + public static readonly string Nbf = "nbf"; | |
54 | + | |
55 | + public static readonly string Exp = "exp"; | |
56 | + | |
57 | + public static readonly string Iat = "iat"; | |
58 | + | |
59 | + public static readonly string[] TimeTlas = { Nbf, Exp, Iat }; | |
60 | + } | |
61 | +} |
@@ -0,0 +1,44 @@ | ||
1 | +using System; | |
2 | +using System.Collections; | |
3 | +using System.Collections.Generic; | |
4 | +using System.Linq; | |
5 | +using System.Threading.Tasks; | |
6 | +using SL.SessionJwt.Service.Model; | |
7 | + | |
8 | +namespace SL.SessionJwt.Service | |
9 | +{ | |
10 | + public class FakeUserRepository : IEnumerable<User> | |
11 | + { | |
12 | + private List<User> _users = new List<User>(); | |
13 | + | |
14 | + public FakeUserRepository() | |
15 | + { | |
16 | + _users.Add(new User(Guid.NewGuid(), "User1", Role.Customer)); | |
17 | + _users.Add(new User(Guid.NewGuid(), "User2", Role.Employee)); | |
18 | + _users.Add(new User(Guid.NewGuid(), "User3", Role.Administrator)); | |
19 | + _users.Add(new User(Guid.NewGuid(), "User4", Role.Customer, Role.Employee)); | |
20 | + _users.Add(new User(Guid.NewGuid(), "User5", Role.Customer, Role.Employee, Role.Administrator)); | |
21 | + } | |
22 | + | |
23 | + public async Task<User> FindUserByName<T>(string name, params object[] exceptionArguments) where T:Exception | |
24 | + { | |
25 | + return await Task<User>.Run(() => | |
26 | + { | |
27 | + var result = _users.FirstOrDefault(e => e.Name == name); | |
28 | + if (null == result) | |
29 | + throw (Exception)Activator.CreateInstance(typeof(T), exceptionArguments); | |
30 | + return result; | |
31 | + }); | |
32 | + } | |
33 | + | |
34 | + public IEnumerator<User> GetEnumerator() | |
35 | + { | |
36 | + return _users.GetEnumerator(); | |
37 | + } | |
38 | + | |
39 | + IEnumerator IEnumerable.GetEnumerator() | |
40 | + { | |
41 | + return _users.GetEnumerator(); | |
42 | + } | |
43 | + } | |
44 | +} |
@@ -0,0 +1,25 @@ | ||
1 | +using System; | |
2 | +using System.Collections.Generic; | |
3 | +using System.IO; | |
4 | +using System.Linq; | |
5 | +using System.Threading.Tasks; | |
6 | +using Microsoft.AspNetCore.Hosting; | |
7 | +using Microsoft.Extensions.Hosting; | |
8 | + | |
9 | +namespace SL.SessionJwt.Service | |
10 | +{ | |
11 | + public class Program | |
12 | + { | |
13 | + public static void Main(string[] args) | |
14 | + { | |
15 | + CreateHostBuilder(args).Build().Run(); | |
16 | + } | |
17 | + | |
18 | + public static IHostBuilder CreateHostBuilder(string[] args) => | |
19 | + Host.CreateDefaultBuilder(args) | |
20 | + .ConfigureWebHostDefaults(webBuilder => | |
21 | + { | |
22 | + webBuilder.UseStartup<Startup>(); | |
23 | + }); | |
24 | + } | |
25 | +} |
@@ -0,0 +1,86 @@ | ||
1 | +using System; | |
2 | +using Auth = Microsoft.AspNetCore.Authentication; | |
3 | +using Microsoft.AspNetCore.Authentication.JwtBearer; | |
4 | +using Microsoft.AspNetCore.Builder; | |
5 | +using Microsoft.AspNetCore.Hosting; | |
6 | +using Microsoft.AspNetCore.Http; | |
7 | +using Microsoft.Extensions.DependencyInjection; | |
8 | +using Microsoft.Extensions.Hosting; | |
9 | +using Microsoft.IdentityModel.Tokens; | |
10 | +using SL.SessionJwt.Service.Services; | |
11 | +using Microsoft.AspNetCore.Authorization; | |
12 | +using Microsoft.IdentityModel.JsonWebTokens; | |
13 | +using System.Security.Claims; | |
14 | +using System.Linq; | |
15 | +using SL.SessionJwt.Service.JwtExtensions; | |
16 | + | |
17 | +namespace SL.SessionJwt.Service | |
18 | +{ | |
19 | + public class Startup | |
20 | + { | |
21 | + public void ConfigureServices(IServiceCollection services) | |
22 | + { | |
23 | + services.AddSingleton<FakeUserRepository>(); | |
24 | + services.AddSingleton<JwtSessionValidator>(); | |
25 | + services.AddSingleton<JwtTokenEvents>(); | |
26 | + | |
27 | + //services.AddCors(o => o.AddPolicy("MyPolicy", builder => | |
28 | + //{ | |
29 | + // builder.AllowAnyOrigin() | |
30 | + // .AllowAnyMethod() | |
31 | + // .AllowAnyHeader() | |
32 | + // .WithExposedHeaders("Token-Expired"); | |
33 | + //})); | |
34 | + | |
35 | + services.AddGrpc(); | |
36 | + | |
37 | + services.AddAuthentication(x => | |
38 | + { | |
39 | + x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; | |
40 | + x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; | |
41 | + }) | |
42 | + .AddJwtBearer(x => | |
43 | + { | |
44 | + x.EventsType = typeof(JwtTokenEvents); | |
45 | + x.RequireHttpsMetadata = false; | |
46 | + x.SaveToken = false; | |
47 | + x.TokenValidationParameters = Constants.TokenValidation; | |
48 | + }); | |
49 | + | |
50 | + services.AddAuthorization(options => | |
51 | + { | |
52 | + options.AddPolicy(JwtBearerDefaults.AuthenticationScheme, policy => | |
53 | + { | |
54 | + policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme); | |
55 | + policy.RequireClaim(ClaimTypes.Name); | |
56 | + policy.RequireClaim(ClaimTypes.NameIdentifier); | |
57 | + policy.RequireClaim(JwtRegisteredClaimNames.Jti); | |
58 | + }); | |
59 | + }); | |
60 | + } | |
61 | + | |
62 | + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) | |
63 | + { | |
64 | + if (env.IsDevelopment()) | |
65 | + { | |
66 | + app.UseDeveloperExceptionPage(); | |
67 | + } | |
68 | + | |
69 | + //app.UseCors("MyPolicy"); | |
70 | + app.UseRouting(); | |
71 | + app.UseAuthentication(); | |
72 | + app.UseAuthorization(); | |
73 | + | |
74 | + | |
75 | + app.UseEndpoints(endpoints => | |
76 | + { | |
77 | + endpoints.MapGrpcService<AuthenticationService>(); | |
78 | + endpoints.MapGrpcService<GreeterService>(); | |
79 | + endpoints.MapGet("/", async context => | |
80 | + { | |
81 | + await context.Response.WriteAsync("Communication with gRPC endpoints must be made through a gRPC client. To learn how to create a client, visit: https://go.microsoft.com/fwlink/?linkid=2086909"); | |
82 | + }); | |
83 | + }); | |
84 | + } | |
85 | + } | |
86 | +} |
@@ -0,0 +1,10 @@ | ||
1 | +{ | |
2 | + "Logging": { | |
3 | + "LogLevel": { | |
4 | + "Default": "Debug", | |
5 | + "System": "Information", | |
6 | + "Grpc": "Information", | |
7 | + "Microsoft": "Information" | |
8 | + } | |
9 | + } | |
10 | +} |
@@ -0,0 +1,15 @@ | ||
1 | +{ | |
2 | + "Logging": { | |
3 | + "LogLevel": { | |
4 | + "Default": "Information", | |
5 | + "Microsoft": "Warning", | |
6 | + "Microsoft.Hosting.Lifetime": "Information" | |
7 | + } | |
8 | + }, | |
9 | + "AllowedHosts": "*", | |
10 | + "Kestrel": { | |
11 | + "EndpointDefaults": { | |
12 | + "Protocols": "Http2" | |
13 | + } | |
14 | + } | |
15 | +} |
@@ -0,0 +1,52 @@ | ||
1 | +What & Why) | |
2 | + | |
3 | +This poc is an extented usage of jwt | |
4 | +to proof a jwt is usable as session cookie too. | |
5 | +I call that a tookie. | |
6 | + | |
7 | +I use a 1 minute session timeout by inactivity here and i send an | |
8 | +updated tookie back to the client at each call in the response header. | |
9 | + | |
10 | +Moreover a client can use an outdated tookie to | |
11 | +recreate a session if tookie is valid/signed and not older than 2 minutes, | |
12 | +we dont use a so called refresh token for that technique. | |
13 | +This recreation is not possible if client does a regular logout before, | |
14 | +only in case of session timeout. | |
15 | + | |
16 | + | |
17 | +Please note: | |
18 | +1) Jwt's are litmited in size, think about before u plan to use it as cookie too. | |
19 | +2) Blocking unary calls in Grpc client does not return any response headers | |
20 | +so we can only use async unary here. (not realy an issue i guess) | |
21 | +3.) I do not handle the other kind of calls in the client invoker: | |
22 | +AsyncServerStreamingCall, AsyncClientStreamingCall, AsyncDuplexStreamingCall | |
23 | +in this poc but its pretty easy to implement with shown AsyncUnaryCall as role model. | |
24 | + | |
25 | + | |
26 | +Service) | |
27 | + | |
28 | +The main job is performed in JwtExtensions by AuthenticationService & JwtTokenEvents. | |
29 | +We use an attached event class to JwtBearerHandler here instead of an interceptor to | |
30 | +avoid decode the token twice. | |
31 | + | |
32 | + | |
33 | +Client) | |
34 | + | |
35 | +The client use a custom invoker to encapsulate AsyncUnaryCall for | |
36 | +send and recieve tokens trough request and response header. | |
37 | + | |
38 | + | |
39 | +Optional) | |
40 | + | |
41 | +1) Change solution start settings to multiple and select server and client in this order. | |
42 | +2) Change session timeout values in service Constants.cs and wait time in client OutdatedTookie.cs | |
43 | +if testing tooks too long 4u. | |
44 | + | |
45 | + | |
46 | +Issues) | |
47 | + | |
48 | +1.) AuthenticationService.cs should look more fluent but its okay for a poc so far. | |
49 | + | |
50 | + | |
51 | +Thats all folks | |
52 | +If u see issues or found and error, please tell me. | |
\ No newline at end of file |
@@ -11,12 +11,13 @@ | ||
11 | 11 | public class GreeterService : Greeter.GreeterBase |
12 | 12 | { |
13 | 13 | private readonly ILogger<GreeterService> _logger; |
14 | + | |
14 | 15 | public GreeterService(ILogger<GreeterService> logger) |
15 | 16 | { |
16 | 17 | _logger = logger; |
17 | 18 | } |
18 | 19 | |
19 | - [Authorize(Roles = "Administrator, Employee")] | |
20 | + [Authorize(Roles = "Administrator, Employee, Customer")] | |
20 | 21 | public override Task<HelloReply> SayHello(HelloRequest request, ServerCallContext context) |
21 | 22 | { |
22 | 23 | return Task.FromResult(new HelloReply |
@@ -16,7 +16,7 @@ | ||
16 | 16 | Service) |
17 | 17 | |
18 | 18 | The Authentication service adds id, name and then user roles as role claims to the token. |
19 | -Greeter service use the Authorize attribute so the jwtbearer can verify one of the roles in our token match | |
19 | +Greeter service use the Authorize attribute so the authorization can verify one of the roles in our token match | |
20 | 20 | and its allowed for the token to call SayHello. Remove 'Customer' Role from authorize attribute |
21 | 21 | in SayHello to see it fails by permission denied. We also say id and name are required token claims. |
22 | 22 |