himeko 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ <h2>400: IAM limit exceeded</h2>
2
+
3
+ <p>Sadly, we have reached some IAM limit during operation...<p>
4
+
5
+ <p><code><%= @iam_error&.inspect %></code></p>
6
+
7
+ <%== conf.dig(:custom_html, :iam_limit_exceeded_error) %>
@@ -0,0 +1,52 @@
1
+
2
+ <div class='access-console-form'>
3
+ <form action='/console' method='POST'>
4
+ <div class='access-console-form-auto access-console-form-auto-enabled'>
5
+ <p>Taking you to console in <span class='access-console-form-auto-sec'>4</span> seconds... <a class='access-console-form-auto-cancel' href='#'>Stop</a></p>
6
+ </div>
7
+ <p><button type='submit' class='btn-primary'>Access to Console</button></p>
8
+ <p><input type='checkbox' name='recreate' value='1' id='recreate'><label for='recreate'>Reinitiailze</label></p>
9
+ <p><small>Choose "Reinitiailze" when you have a change in your IAM permissions</small></p>
10
+ </form>
11
+ <div class='access-console-form-loading'>
12
+ <p>Logging into console... (This could take up to 10-20 seconds)</p>
13
+ </div>
14
+ </div>
15
+
16
+ <%== conf.dig(:custom_html, :index) %>
17
+
18
+ <script>
19
+ "use strict";
20
+ document.addEventListener("DOMContentLoaded", () => {
21
+ document.body.addEventListener('click', () => {
22
+ document.body.querySelectorAll('.access-console-form-auto-enabled').forEach((elem) => {
23
+ elem.classList.remove('access-console-form-auto-enabled');
24
+ });
25
+ });
26
+ document.querySelectorAll('.access-console-form').forEach((elem) => {
27
+ elem.querySelector('form').addEventListener('submit', (e) => {
28
+ elem.classList.add('access-console-form-submitted');
29
+ });
30
+ elem.querySelector('.access-console-form-auto-cancel').addEventListener('click', (e) => {
31
+ elem.querySelector('.access-console-form-auto').classList.remove('access-console-form-auto-enabled');
32
+ e.preventDefault();
33
+ });
34
+
35
+ const countDown = function() {
36
+ const secElem = elem.querySelector('.access-console-form-auto-sec');
37
+ var sec = parseInt(secElem.innerHTML, 10);
38
+ sec -= 1;
39
+ secElem.innerHTML = `${sec}`;
40
+ if (sec < 1) {
41
+ if (elem.querySelector('.access-console-form-auto-enabled')) {
42
+ elem.querySelector('form').submit();
43
+ elem.classList.add('access-console-form-submitted');
44
+ }
45
+ } else {
46
+ setTimeout(countDown, 1000);
47
+ }
48
+ };
49
+ setTimeout(countDown, 1000);
50
+ });
51
+ });
52
+ </script>
@@ -0,0 +1,44 @@
1
+ <h2>Keys</h2>
2
+
3
+ <p>List of IAM access key for <code><%= current_username %></code>:</p>
4
+
5
+ <section class='iam-keys'>
6
+ <%- @keys.each do |key, last_used| -%>
7
+ <div class='iam-key'>
8
+ <div class='iam-key-info'>
9
+ <h3><%= key.access_key_id %></h3>
10
+ Status: <%= key.status %><br>
11
+ Since: <%= key.create_date %><br>
12
+ Last Used: <%= last_used.service_name %> @ <%= last_used.region %> / <%= last_used.last_used_date %>
13
+ </div>
14
+
15
+ <div class='iam-key-actions'>
16
+ <form action='/keys/<%= key.access_key_id %>' method='POST' onsubmit="return confirm('Are you sure? This cannot be undone.')">
17
+ <input type='hidden' name='_method' value='DELETE'>
18
+ <button type='submit' class='btn-danger'>Delete</button>
19
+ </form>
20
+
21
+ <%- if key.status == 'Active' -%>
22
+ <form action='/keys/<%= key.access_key_id %>/active' method='POST' onsubmit="return confirm('Are you sure?')">
23
+ <input type='hidden' name='_method' value='DELETE'>
24
+ <button type='submit' class='btn'>Disable</button>
25
+ </form>
26
+ <%- else -%>
27
+ <form action='/keys/<%= key.access_key_id %>/active' method='POST'>
28
+ <button type='submit' class='btn'>Activate</button>
29
+ </form>
30
+ <%- end -%>
31
+ </div>
32
+ </div>
33
+ <%- end -%>
34
+ </section>
35
+
36
+ <%- if @keys.size < 2 -%>
37
+ <form action='/keys' method='POST'>
38
+ <button type='submit' class='btn-primary'>Create</button>
39
+ </form>
40
+ <%- else -%>
41
+ <p>IAM user cannot have more than 2 access keys at once. Delete an existing key to create new one; <a href='https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_iam-limits.html#reference_iam-limits-entities'>AWS docs</a><p>
42
+ <%- end -%>
43
+
44
+
@@ -0,0 +1,45 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset='utf-8'>
5
+ <meta name='viewport' content='width=device-width, minimum-scale=1'>
6
+ <title>AWS self-service portal | Himeko</title>
7
+ <link rel='stylesheet' href='/style.css' type="text/css">
8
+ </head>
9
+
10
+ <body>
11
+ <div class="container">
12
+ <header class='header'>
13
+ <h1 class='logo'>
14
+ <a href='/'>AWS self-service</a>
15
+ </h1>
16
+
17
+ <nav>
18
+ <a href='/'>Console</a>
19
+ <a href='/keys'>Access Keys</a>
20
+ </nav>
21
+ </header>
22
+
23
+ <% notice ||= session.delete(:notice); error ||= session.delete(:error) %>
24
+ <% if notice %>
25
+ <div class="notice"><%= notice %></div>
26
+ <% end %>
27
+
28
+ <% if error %>
29
+ <div class="error"><%= error %></div>
30
+ <% end %>
31
+
32
+ <div class="box">
33
+ <%== yield %>
34
+ </div>
35
+
36
+ <footer>
37
+ <p>Logged in as <%== current_username %></p>
38
+ <div class="credit">
39
+ Powered by <a href="https://github.com/sorah/himeko">sorah/himeko</a>
40
+ </div>
41
+ </footer>
42
+ </div>
43
+ </body>
44
+ </html>
45
+
@@ -0,0 +1,35 @@
1
+ <h2>New IAM Access Key Created</h2>
2
+
3
+ <p>New IAM access key has been created for <code><%= @key.user_name %></code>. Note that you won't be able to see the secret key again!</p>
4
+
5
+ <%== conf.dig(:custom_html, :new_key_guidance) %>
6
+
7
+ <section class='iam-secret-key'>
8
+ <div>
9
+ <h4>AWS_ACCESS_KEY_ID</code></h4>
10
+ <pre class='iam-copyable'><code><%= @key.access_key_id %></code></pre>
11
+ </div>
12
+ <div>
13
+ <h4>AWS_SECRET_ACCESS_KEY</code></h4>
14
+ <pre class='iam-copyable'><code><%= @key.secret_access_key %></code></pre>
15
+ </div>
16
+ </section>
17
+
18
+ <script>
19
+ "use strict";
20
+ document.addEventListener('DOMContentLoaded', () => {
21
+ document.querySelectorAll('.iam-copyable > code').forEach((e) => {
22
+ e.addEventListener('mouseenter', (e) => {
23
+ const range = document.createRange();
24
+ range.selectNodeContents(e.target);
25
+ const selection = window.getSelection();
26
+ selection.removeAllRanges();
27
+ selection.addRange(range);
28
+ });
29
+ e.addEventListener('mouseleave', (e) => {
30
+ const selection = window.getSelection();
31
+ selection.removeAllRanges();
32
+ });
33
+ });
34
+ });
35
+ </script>
@@ -0,0 +1,5 @@
1
+ <h2>403: No IAM user found</h2>
2
+
3
+ <p>We couldn't find IAM user <code><%== current_username %></code> to proceed.</p>
4
+
5
+ <%== conf.dig(:custom_html, :no_user_error) %>
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "himeko"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,96 @@
1
+ require 'bundler/setup'
2
+ require 'securerandom'
3
+
4
+ require 'omniauth'
5
+
6
+ require 'himeko'
7
+
8
+ if ENV['RACK_ENV'] == 'production'
9
+ raise 'Set $SECRET_KEY_BASE' unless ENV['SECRET_KEY_BASE']
10
+ end
11
+
12
+ dev = ENV.fetch('RACK_ENV', 'development') == 'development'
13
+
14
+ use(
15
+ Rack::Session::Cookie,
16
+ key: 'himekosess',
17
+ expire_after: 3600,
18
+ secure: ENV.fetch('HIMEKO_SECURE_SESSION', ENV['RACK_ENV'] == 'production' ? '1' : nil) == '1',
19
+ secret: ENV.fetch('SECRET_KEY_BASE', SecureRandom.base64(256)),
20
+ )
21
+
22
+ provider = nil
23
+ case
24
+ when ENV['HIMEKO_GITHUB_KEY'] && ENV['HIMEKO_GITHUB_SECRET']
25
+ require 'omniauth-github'
26
+ gh_client_options = {}
27
+ if ENV['HIMEKO_GITHUB_HOST']
28
+ gh_client_options[:site] = "#{ENV['HIMEKO_GITHUB_HOST']}/api/v3"
29
+ gh_client_options[:authorize_url] = "#{ENV['HIMEKO_GITHUB_HOST']}/login/oauth/authorize"
30
+ gh_client_options[:token_url] = "#{ENV['HIMEKO_GITHUB_HOST']}/login/oauth/access_token"
31
+ end
32
+
33
+ gh_scope = ''
34
+ if ENV['HIMEKO_GITHUB_TEAMS']
35
+ gh_scope = 'read:org'
36
+ end
37
+
38
+ use OmniAuth::Builder do
39
+ provider(:github, ENV['HIMEKO_GITHUB_KEY'], ENV['HIMEKO_GITHUB_SECRET'], client_options: gh_client_options, scope: gh_scope)
40
+ end
41
+ provider = :github
42
+ when ENV['HIMEKO_GOOGLE_KEY'] && ENV['HIMEKO_GOOGLE_SECRET']
43
+ require 'omniauth-google-oauth2'
44
+ use OmniAuth::Builder do
45
+ provider(:google_oauth2, ENV['HIMEKO_GOOGLE_KEY'], ENV['HIMEKO_GOOGLE_SECRET'], hd: ENV['HIMEKO_GOOGLE_HD'])
46
+ end
47
+ provider = :google_oauth2
48
+ when dev
49
+ use OmniAuth::Builder do
50
+ provider(:developer, fields: %i(uid), uid_field: :uid)
51
+ end
52
+ provider = :developer
53
+ end
54
+
55
+ use(Class.new do
56
+ def initialize(app, provider)
57
+ @app = app
58
+ @provider = provider
59
+ end
60
+
61
+ def call(env)
62
+ return process_callback(env) if env['omniauth.auth']
63
+ session = env.fetch('rack.session')
64
+
65
+ user = env['himeko.user'] = session[:user]
66
+ unless user
67
+ session[:back_to] ||= env['PATH_INFO']
68
+ return [302, {'Location' => "/auth/#{@provider}"}, []]
69
+ end
70
+
71
+ @app.call env
72
+ end
73
+
74
+ def process_callback(env)
75
+ session = env.fetch('rack.session')
76
+ auth = env.fetch('omniauth.auth')
77
+ case auth.fetch(:provider)
78
+ when 'github'
79
+ session[:user] = auth.fetch(:info).fetch(:nickname)
80
+ when 'google_oauth2'
81
+ session[:user] = auth.fetch(:info).fetch(:email).split(?@,2)[0]
82
+ when 'developer'
83
+ session[:user] = auth.fetch(:uid)
84
+ end
85
+ return [302, {'Location' => session.delete(:back_to) || '/'}, []]
86
+ end
87
+ end, provider)
88
+
89
+ config = {
90
+ role_path: ENV.fetch('HIMEKO_ROLE_PATH', '/user-role/'),
91
+ role_prefix: ENV.fetch('HIMEKO_ROLE_PREFIX', 'user_'),
92
+ dynamodb_table_name: ENV.fetch('HIMEKO_DYNAMODB_TABLE'),
93
+ session_duration: ENV.fetch('HIMEKO_SESSION_DURATION', 3600).to_i,
94
+ }
95
+
96
+ run Himeko.app(config)
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env ruby
2
+ require 'himeko'
3
+ require 'logger'
4
+ require 'aws-sdk-dynamodb'
5
+ require 'aws-sdk-iam'
6
+
7
+ config = {
8
+ role_path: ENV.fetch('HIMEKO_ROLE_PATH', '/user-role/'),
9
+ role_prefix: ENV.fetch('HIMEKO_ROLE_PREFIX', 'user_'),
10
+ dynamodb_table_name: ENV.fetch('HIMEKO_DYNAMODB_TABLE', 'himeko-staging'),
11
+ }
12
+
13
+ Himeko::RoleManager.new(
14
+ iam: Aws::IAM::Client.new(logger: Logger.new($stdout)),
15
+ path: config[:role_path],
16
+ prefix: config[:role_prefix],
17
+ dynamodb_table: Aws::DynamoDB::Resource.new(logger: Logger.new($stdout)).table(config[:dynamodb_table_name]),
18
+ ).prune
@@ -0,0 +1,34 @@
1
+
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require "himeko/version"
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "himeko"
8
+ spec.version = Himeko::VERSION
9
+ spec.authors = ["Sorah Fukumori"]
10
+ spec.email = ["sorah@cookpad.com"]
11
+
12
+ spec.summary = %q{AWS IAM access key self service & management console federated login}
13
+ spec.homepage = "https://github.com/sorah/himeko"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
17
+ f.match(%r{^(test|spec|features)/})
18
+ end
19
+ spec.bindir = "exe"
20
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
21
+ spec.require_paths = ["lib"]
22
+
23
+ spec.add_dependency "aws-sdk-core" # aws-sdk-sts
24
+ spec.add_dependency "aws-sdk-iam"
25
+ spec.add_dependency "aws-sdk-dynamodb"
26
+
27
+ spec.add_dependency "sinatra"
28
+ spec.add_dependency "rack-protection"
29
+ spec.add_dependency "erubi"
30
+
31
+ spec.add_development_dependency "bundler"
32
+ spec.add_development_dependency "rake"
33
+ spec.add_development_dependency "rspec", "~> 3.0"
34
+ end
@@ -0,0 +1,2 @@
1
+ require "himeko/version"
2
+ require "himeko/app"
@@ -0,0 +1,177 @@
1
+ require 'open-uri'
2
+ require 'uri'
3
+ require 'json'
4
+ require 'erubi'
5
+ require 'sinatra/base'
6
+ require 'rack/protection'
7
+
8
+ require 'aws-sdk-core' # sts
9
+ require 'aws-sdk-iam'
10
+ require 'aws-sdk-dynamodb'
11
+
12
+ require 'himeko/role_manager'
13
+
14
+ module Himeko
15
+ def self.app(*args)
16
+ App.rack(*args)
17
+ end
18
+
19
+ class App < Sinatra::Base
20
+ CONTEXT_RACK_ENV_NAME = 'himeko.ctx'
21
+ USER_RACK_ENV_NAME = 'himeko.user'
22
+
23
+ def self.initialize_context(config)
24
+ {
25
+ config: config,
26
+ }
27
+ end
28
+
29
+ def self.rack(config={})
30
+ klass = App
31
+
32
+ context = initialize_context(config)
33
+ lambda { |env|
34
+ env[CONTEXT_RACK_ENV_NAME] = context
35
+ klass.call(env)
36
+ }
37
+ end
38
+
39
+ configure do
40
+ enable :logging
41
+ end
42
+
43
+ set :root, File.expand_path(File.join(__dir__, '..', '..', 'app'))
44
+ set :erb, escape_html: true
45
+
46
+ use Rack::MethodOverride
47
+ use Rack::Protection
48
+
49
+ helpers do
50
+ def context
51
+ request.env[CONTEXT_RACK_ENV_NAME]
52
+ end
53
+
54
+ def conf
55
+ context[:config]
56
+ end
57
+
58
+ def current_username
59
+ name = request.env[USER_RACK_ENV_NAME]
60
+ halt 401, "request.env[#{USER_RACK_ENV_NAME}] is missing (maybe a configuration bug!)" unless name
61
+ name
62
+ end
63
+
64
+ def sts
65
+ @sts ||= context[:sts] ||= conf[:sts] || Aws::STS::Client.new(logger: env['rack.logger'])
66
+ end
67
+
68
+ def iam
69
+ @iam ||= context[:iam] ||= conf[:iam] || Aws::IAM::Client.new(logger: env['rack.logger'])
70
+ end
71
+
72
+ def dynamodb_table
73
+ @dynamodb_table ||= context[:dynamodb_table] ||= conf[:dynamodb_table] ||= Aws::DynamoDB::Resource.new().table(conf.fetch(:dynamodb_table_name))
74
+ end
75
+
76
+ def role_manager
77
+ @role_manager ||= context[:role_manager] ||= RoleManager.new(
78
+ iam: iam,
79
+ prefix: conf.fetch(:role_prefix),
80
+ path: conf.fetch(:role_path),
81
+ ttl: conf.fetch(:role_ttl, 86400),
82
+ dynamodb_table: dynamodb_table,
83
+ )
84
+ end
85
+
86
+ def console_session_duration
87
+ conf.fetch(:session_duration, 3600)
88
+ end
89
+
90
+ def render_no_user_error
91
+ status 403
92
+ erb :no_user_error
93
+ end
94
+ end
95
+
96
+ get '/' do
97
+ erb :index
98
+ end
99
+
100
+ post '/console' do
101
+ recreate = params[:recreate] == '1'
102
+ begin
103
+ arn = role_manager.fetch(current_username, recreate: recreate)
104
+ rescue Aws::IAM::Errors::LimitExceeded => e
105
+ @iam_error = e
106
+ status 400
107
+ return erb :iam_limit_exceeded_error
108
+ end
109
+
110
+ retries = 0
111
+ resp = nil
112
+ begin
113
+ resp = sts.assume_role(
114
+ duration_seconds: console_session_duration,
115
+ role_arn: arn,
116
+ role_session_name: current_username,
117
+ )
118
+ rescue Aws::STS::Errors::AccessDenied
119
+ raise if retries > 5
120
+ sleep 1 + (1.1**retries)
121
+ retries += 1
122
+ retry
123
+ end
124
+ json = {sessionId: resp.credentials.access_key_id, sessionKey: resp.credentials.secret_access_key, sessionToken: resp.credentials.session_token}.to_json
125
+ signin_token = JSON.parse(open("https://signin.aws.amazon.com/federation?Action=getSigninToken&Session=#{URI.encode_www_form_component(json)}", 'r', &:read))
126
+
127
+ url = "https://signin.aws.amazon.com/federation?Action=login&Issuer=#{URI.encode_www_form_component(request.base_url)}&Destination=#{URI.encode_www_form_component(params[:relay] || 'https://console.aws.amazon.com/console/home')}&SigninToken=#{signin_token.fetch("SigninToken")}"
128
+
129
+ redirect url
130
+ end
131
+
132
+ get '/keys' do
133
+ @keys = iam.list_access_keys(user_name: current_username)
134
+ .access_key_metadata.map do |key_data|
135
+ [
136
+ key_data,
137
+ iam.get_access_key_last_used(access_key_id: key_data.access_key_id).access_key_last_used,
138
+ ]
139
+ end
140
+ erb :keys
141
+ rescue Aws::IAM::Errors::NoSuchEntity
142
+ return render_no_user_error()
143
+ end
144
+
145
+ post '/keys' do
146
+ #\@key = Struct.new(:user_name, :access_key_id, :secret_access_key).new('foobar', 'DUMMY123', 'secret+secret')
147
+ @key = iam.create_access_key(user_name: current_username).access_key
148
+ erb :new_key
149
+ rescue Aws::IAM::Errors::NoSuchEntity
150
+ return render_no_user_error()
151
+ end
152
+
153
+ delete '/keys/:id' do
154
+ iam.delete_access_key(access_key_id: params[:id], user_name: current_username)
155
+ session[:notice] = "Access key #{params[:id]} has been deleted."
156
+ redirect '/keys'
157
+ rescue Aws::IAM::Errors::NoSuchEntity
158
+ halt 404, 'NoSuchEntity'
159
+ end
160
+
161
+ post '/keys/:id/active' do
162
+ iam.update_access_key(access_key_id: params[:id], user_name: current_username, status: 'Active')
163
+ session[:notice] = "Access key #{params[:id]} has been activated."
164
+ redirect '/keys'
165
+ rescue Aws::IAM::Errors::NoSuchEntity
166
+ halt 404, 'NoSuchEntity'
167
+ end
168
+
169
+ delete '/keys/:id/active' do
170
+ iam.update_access_key(access_key_id: params[:id], user_name: current_username, status: 'Inactive')
171
+ session[:notice] = "Access key #{params[:id]} has been deactivated."
172
+ redirect '/keys'
173
+ rescue Aws::IAM::Errors::NoSuchEntity
174
+ halt 404, 'NoSuchEntity'
175
+ end
176
+ end
177
+ end