himeko 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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